Prufy notes
The app · 9 min

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.

The path of a requestClick a layer

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.

Handler
validates what it sends
contract.ts
one schema per route
Browser client
parses what it gets

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.

Status codes and what they mean
400
Malformed request
bad_request
401
No session
unauthorized
404
Not found, or not yours
not_found domain_connect_disabled
409
Not possible right now
check_in_flight not_checkable illegal_transition version_conflict owner_conflict domain_connect_unavailable
422
That can't be used as a domain
empty invalid_characters label_too_long too_long ip_address reserved_tld public_suffix single_label
429
Slow down
rate_limited
500
A bug on our side
internal_error
503
That channel isn't set up on this server
email_unavailable push_unavailable

The composition root

The composition rootEverything with a side effect, injected
db
A Postgres pool on DATABASE_URL
dns
Real DoH and nameserver queries
clock
The real time
rateLimiter
In memory
mailer
Resend, or null when RESEND_API_KEY isn't set
push
web-push with VAPID keys, or null without them
logger
One JSON object per line
env
Read from the environment
This one seam is why a test can jump 7 days forward, stage any DNS failure, and call a handler directly with new Request().

The worker

1
On a timer
runDueWork wakes up: every 15 seconds locally, every minute on Vercel.
2
Lease up to 10 claims
dueForCheck takes claims whose next_check_at has passed, for 60 seconds.
3
Decide what each needs
dueAction picks one: expire it, end its grace, or run a check.
4
Do it
A check goes through the same performCheck a person's click does.
5
Reschedule
The transition writes next_check_at and releases the lease.

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.

Same worker, two ways to wake itSwitch host
What wakes the worker
Vercel Cron, calling /api/cron/run-due-work
How often
Every minute
Who can start it
Whoever holds CRON_SECRET, sent as a bearer token
WORKER_ENABLED
false
Database
Neon, through its pooled connection
Runs on
Real Node, in a Vercel Function
Both paths end in the same runDueWork, so the batch size, the leases and the rules don't change.

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.

Predict it first
Someone finds /api/cron/run-due-work and calls it with no header. What happens?

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 RateLimiter on the container (packages/db/src/queries/rate-limit.ts). Each counted action is a row in rate_limit_hits, the window slides, and pg_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.
Predict it first
Someone requests another account's claim by guessing its id. What do they get?