Back to Ideas 4 min read

From Convention to Plugin

E
EkoHacks Team
·

This refactor replaces a five step CLAUDE.md convention with a Fastify plugin. The code got shorter, the failure mode got louder, and three files shrank. Here is what actually changed.

Before

Every webhook handler did its own signature verification inline. Two routes, same boilerplate:

interface WebhookRequest extends FastifyRequest {
  rawBody?: string | Buffer;
}

server.post('/github', { schema, config: { rawBody: true } },
  async (request: WebhookRequest, reply) => {
    const signature = request.headers['x-hub-signature-256'] as string | undefined;
    const secret = process.env.GITHUB_WEBHOOK_SECRET;
    if (!secret) return reply.internalServerError('Webhook secret not configured');
    if (!request.rawBody) return reply.internalServerError('Raw body not available');

    const rawBodyString = typeof request.rawBody === 'string'
      ? request.rawBody
      : request.rawBody.toString('utf-8');

    if (!verifyGitHubSignature(rawBodyString, signature, secret)) {
      return reply.unauthorized('Invalid signature');
    }

    // ... actual handler work starts here
  }
);

server/src/routes/webhooks.ts was 249 lines. The GitHub handler was 131 lines, the Qodana handler 113. About 30 lines per handler were signature plumbing duplicated across both. Plus a WebhookRequest interface that existed only to widen FastifyRequest.rawBody.

server/CLAUDE.md had a five step procedure that new webhook routes had to follow "or signature verification silently fails". Step 4 was the actual check. Steps 1, 2, 3, 5 were ceremony.

After

One plugin. 45 lines. Reads route config, runs the check, short circuits with the right status code:

// server/src/plugins/webhook-signature.ts
const webhookSignaturePlugin: FastifyPluginAsync = async (fastify) => {
  fastify.addHook('preHandler', async (request, reply) => {
    const sigConfig = request.routeOptions.config.webhookSignature;
    if (!sigConfig) return;

    const secret = process.env[sigConfig.secretEnv];
    if (!secret) return reply.internalServerError('Webhook secret not configured');

    const rawBody = (request as RequestWithRawBody).rawBody;
    if (!rawBody) return reply.internalServerError('Raw body not available');

    const rawBodyString = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
    const signature = request.headers[sigConfig.header.toLowerCase()] as string | undefined;

    if (!verifyGitHubSignature(rawBodyString, signature, secret)) {
      return reply.unauthorized('Invalid signature');
    }
  });
};

Registered once in server/src/index.ts, after rawBody. Routes opt in via config:

server.post('/github', {
  schema: { /* ... */ },
  config: {
    rawBody: true,
    webhookSignature: {
      secretEnv: 'GITHUB_WEBHOOK_SECRET',
      header: 'x-hub-signature-256',
    },
  },
}, async (request, reply) => {
  // Handler only sees verified requests.
});

The Qodana route uses the same plugin with secretEnv: 'QODANA_WEBHOOK_SECRET' and a different header.

The deltas

FileBeforeAfterChange
server/src/routes/webhooks.ts249 lines192 lines-57
server/src/plugins/webhook-signature.tsabsent45 lines+45
server/CLAUDE.md28 lines11 lines-17
CLAUDE.md (root)24 lines19 lines-5

Net source change: about 12 fewer lines overall, but the meaningful delta is where the logic lives. Signature verification used to be duplicated across two handlers plus documented in a CLAUDE.md procedure. Now it lives in one place, invoked declaratively.

Tests: server/test/routes/webhooks.test.ts runs 24 cases. The only change needed was registering the plugin in test setup alongside sensible and rawBody. All 24 still pass with the same assertions.

What the plugin buys

Failure mode changed. Before, omitting the signature check was a silent "my endpoint accepts forged webhooks" bug. After, the thing you forget is setting a config flag called webhookSignature. Its absence is visible at PR review. You can still write an insecure route, but you cannot do it by accident.

Error paths unified. Missing secret, missing raw body, invalid signature: all three now return from the same preHandler with consistent status codes (500, 500, 401). Handlers no longer branch on these cases.

Handler bodies are about the work. The GitHub handler now starts with event routing. The Qodana handler starts with processQodanaReport. No 30 line preamble on either.

Type narrowing came for free. The handlers dropped the WebhookRequest interface. By the time a handler runs, the signature has been verified at the hook layer, and the handler signature is back to plain (request, reply).

Config is the API. Adding a third webhook (say, Stripe) is now: declare config.webhookSignature = { secretEnv: 'STRIPE_WEBHOOK_SECRET', header: 'stripe-signature' }. No copying verification boilerplate from another route.

What the CLAUDE.md deletion buys

The five step procedure in server/CLAUDE.md was not just noise. It was a signal that signature verification was manual, easy to get wrong, and not enforceable at PR review without the document as a checklist. Removing it is only legitimate because the thing it described got replaced by a mechanism that cannot be skipped by accident.

That is the point of the exercise. A line in CLAUDE.md that tells you to remember to do a thing is a bug report against the codebase, the idea behind our Landmines rule. Fixing it in code lets you delete the line. The file shrinks every time we turn a convention into a constraint, which is the direction it should go.

What is still manual

One CLAUDE.md landmine still stands:

API client types in web/src/lib/api/client.ts are manual. If you change a server endpoint, update client types by hand.

The fix there is an OpenAPI codegen pipeline: export the spec from @fastify/swagger, run openapi-typescript, swap client.ts to use the generated types. The plumbing is cheap. The expensive part is auditing every route to declare a complete response schema, because the generated types are only as good as the spec. That is a separate refactor, scoped separately.

E

Written by

EkoHacks Team

More from Ideas

·6 min read

The Gate Went Red, and It Was Right

We added the missing check on our API specification, pushed it, and it failed on its first run. Not because anyone had forgotten to regenerate the file, but because the file was never a function of the source. It depended on whose environment last produced it, and only a machine with no environment could ever have noticed.

E
EkoHacks Team
·6 min read

Ready to Ship, Not Ready to Release

Most teams have a hidden delay between saying done and being able to ship. The goal is to be technologically ready to release at any moment, even when the feature is half built. Why our deploy workflow ships the commit that was tested rather than the newest one.

E
EkoHacks Team
·6 min read

Never Break the Build

If the software worked five minutes ago, only five minutes of change can be to blame, and debugging turns into reading. That is what a green trunk buys. It is bought by agreement, not by tooling, and one of our own checks turns out to be incapable of failing.

E
EkoHacks Team
EkoHacks

EkoHacks is a coding institute teaching employable software engineering through real projects. We build civic technology and train developers, born in Nigeria, launching in rural Greece. Global tech, local impact.

Stay Tuned

Be the first to hear about new dojos, insights, and opportunities from EkoHacks.