The server
One Next.js app is the landing page, the claim flow and the API. The server code is arranged so each layer has one job and every side effect can be swapped.
Parses the input, calls the service, and maps the result. Thin on purpose.
createClaim: async (request) => {
const body = await parseBody(request, contract.createClaim.body)
const domain = normalizeDomain(body.value.input) // refused here, nothing is written
const session = await sessions.open(request, requestId)
const result = await service.createOwnedClaim(session.value.auth, body.value.input)
return session.value.attach(respond(contract.createClaim.response, result, requestId, status))
}
One contract, both ends
lib/contract.ts holds a Zod schema for each route. The handler validates what it sends, and the browser client parses what it receives, with the same schema. Types are inferred from it, so a request or response shape is never written twice.
Every error, one map
Every error has the same shape, and STATUS_BY_KIND is the only place that picks an HTTP status. It's a Record over the union, so a new kind doesn't compile until it has one.
The composition root
The worker
A serverless function only runs while it's handling a request, so a timer can't be trusted to keep firing there. That's why the same worker has two ways to wake up.
Locally it starts from instrumentation.ts when the server boots, and never during next build. A globalThis guard stops hot reloads starting a second one. Leases make overlap safe in both places, including a slow scheduled run that's still going when the next one starts.
Five pieces, built once from the container
createApi in api.ts builds everything from the container, once: createAlerts, then createClaimService (which is handed the alerts), createShareService, createNotificationService and createSessions. Each is a plain function returning functions, so a test builds the same thing with a different container.
Rate limits and logging
- Manual checks: one per 10 seconds per claim. The response says when you can try again.
- New claims: 20 per hour per account.
- New sessions: 10 per hour per IP address. Without sign-up, this is what stops one client becoming many accounts.
- Setup links: 10 per hour per claim, or 5 per hour per account when the link is emailed.
- Email changes and test alerts: 5 and 10 per hour per account.
- All of them are counted in Postgres, by one
RateLimiteron the container (packages/db/src/queries/rate-limit.ts). Each counted action is a row inrate_limit_hits, the window slides, andpg_advisory_xact_lock(hashtext(key))makes two requests for the same key take turns. A test sends 20 at once against a limit of 5, and exactly 5 get through. - Logs: one JSON object per line with a request id. Tokens are cut to their first 6 characters.