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
| File | Before | After | Change |
|---|---|---|---|
server/src/routes/webhooks.ts | 249 lines | 192 lines | -57 |
server/src/plugins/webhook-signature.ts | absent | 45 lines | +45 |
server/CLAUDE.md | 28 lines | 11 lines | -17 |
CLAUDE.md (root) | 24 lines | 19 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.tsare 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.

