HTTP routes of your own
Everything your clients call is a procedure: declared in the contract, implemented once, typed on
both sides (Where your code goes). A few things cannot be one. An
<img src> or a <video> sends no bearer token and no X-CSRF-Token, a grid of thumbnails makes
hundreds of requests a minute from one address, and bytes want a cache-control of their own rather
than the API’s private, no-store. For those, apps/api/src/product-http.ts mounts plain Hono
routes on the API.
import type { Hono } from 'hono';import { mountMedia } from './media/route';
export function mountProductRoutes(app: Hono): void { mountMedia(app);}
/** Successful thumbnails stay out of the access log and the traces. */export const productQuietPathPrefixes: readonly string[] = ['/api/media/'];Keep a route in a file of its own (apps/api/src/media/route.ts above) and only call it from
product-http.ts, the one file here that is yours and that app.ts reads.
Where they sit
Section titled “Where they sit”app.ts calls mountProductRoutes(app) after the kit’s routes and before its notFound handler.
- In front of yours: the request id (
X-Request-Id), the visitor’s address the web server vouched for, whichclientIpfrom./middleware/rate-limitreads, the tracing span, the access log, the security headers (nosniff, adefault-src 'none'policy on the response, a cross-originCross-Origin-Resource-Policyso a page on the app’s origin can show what the API serves), thecache-controldefault, CORS for the origins intrustedOrigins(), and the API’s body cap: the default, or the cap your prefix declares inproduct-body-limits.ts(Limits). - Not in front of yours: the procedure limiter and the 1 MiB body cap that cover
/rpc/*and/api/v1/*, theX-CSRF-Tokencheck of/rpc/*and the forged-request check of/api/v1/*, and oRPC’s input validation. A route brings each of these itself. A refusal it throws still keeps its status (The session). - Invisible to the contract, the typed client and the OpenAPI document.
And to
tenancy.test.ts, which walks procedures only.
Mount under /api/, for example /api/media/. A single-origin deployment’s Caddy forwards
/api/*, /rpc/* and the probes to the API and everything else to the web app, so a route
elsewhere is never reached there; the subdomain layout forwards everything on api.. Never mount
under /api/v1, the procedures’ REST transport, or /api/auth, which is Better Auth’s.
Authorization
Section titled “Authorization”A route authorizes each request itself. There are two shapes.
A signature. An authorized procedure hands out a short-lived signed URL, and the route checks
the signature instead of a session. It is the only shape an <img> in the desktop and mobile
shells can use, since they authenticate with a bearer token a tag cannot send.
- Sign with HMAC-SHA256 over the path and the expiry, with a secret from
@repo/env(a variable of yours inpackages/env/src/product.ts, listed inproductSecretNames), and compare withtimingSafeEqualfromnode:crypto. - Put them in the query string as
expandsig. Tracing keeps no query string on any span, and both Caddyfiles deletesigfrom their access logs. - Round the expiry up to a window (six hours, say) so every URL in it is identical and the browser
cache keeps working. The cost is that access granted once lasts until
exp: keep the window as short as the cache allows. - The procedure that signs is a normal procedure, guarded like any other, so the check that matters happens there.
A token you issue outlives the session it was handed to. A ban and “sign out everywhere” end the
user’s sessions, which every procedure reads, and never reach a URL signed before them. So sign the
user’s id with the path, and have the route ask again whether that user is still let in, on an
interval you choose: isBanned and hasLiveSession from @repo/db, or accessStateOf, which
answers both in one query. Cache the answer per user for that interval, so a grid of thumbnails
costs one query rather than one per image:
import { accessStateOf } from '@repo/db';
const RECHECK_MS = 60_000;const verdicts = new Map<string, { allowed: boolean; at: number }>();
/** May the user a token names still use it? Asked again once a minute, not on every request. */async function stillLetIn(userId: string): Promise<boolean> { const known = verdicts.get(userId); if (known && Date.now() - known.at < RECHECK_MS) return known.allowed; const { banned, hasLiveSession } = await accessStateOf(userId); const allowed = !banned && hasLiveSession; verdicts.set(userId, { allowed, at: Date.now() }); return allowed;}A token that grants something inside an organization asks for the membership as well, on the same
interval: findMember(userId, organizationId, { permissions, workspaceId, stillSignedIn: true })
from ./rpc/tenancy answers { member, workspace }, or null once the user is banned, holds no
live session, has left the organization or lost the permission, or may no longer see the
workspace. Its member is built as the tenant guards build theirs, so a visibility rule of yours
applies too:
import { findMember } from './rpc/tenancy';
const allowed = (await findMember(token.userId, token.organizationId, { permissions: { document: ['read'] }, workspaceId: token.workspaceId, stillSignedIn: true, })) !== null;Cache the answer per user, organization and workspace as stillLetIn does.
Answer 403 with no-store when it says no. The answer is about the user, not one session: once
they sign in again, a URL signed before “sign out everywhere” works again until its exp. A token
that must end with the session it was handed to signs that session’s id instead, and the route
looks that session up.
Or it revokes the token in productSessionEnded (packages/auth/src/product-sessions.ts), which
the kit calls once for every session that ends: a sign-out, a revoke, a ban, a password reset, a
deleted account. A session that simply expires is reported only if it is presented again, so keep
the re-check as well.
A public demo’s sandbox removed because its visitor could not be signed in reports its
sessions with the cause other; the nightly reset drops the whole database and reports nothing.
The web app can also drop or revoke a token it holds as it signs out: productOnSignOut()
(apps/web/src/lib/product-sign-out.ts) runs before this browser signs out, while the session
still authenticates its calls. It runs only for this browser’s own sign-out, so the API’s check,
or productSessionEnded, stays the guarantee.
A query of your own on user.ban_expires or session.expires_at compares them with
now() at time zone 'UTC', never a bare now(). Both hold UTC in columns without a time zone, and
against a bare now() Postgres reads them in the server’s zone: hours off on a server that does
not run UTC, and right on one that does, such as a CI image. inServerTimeZone from @repo/db
runs a test’s query under a zone far from UTC, where the mistake shows.
The session. A route that a signed-in browser opens directly, such as a ZIP download, reads the session the way the API does:
import { auth } from '@repo/auth/server';
const session = await auth.api.getSession({ headers: c.req.raw.headers });if (!session) return c.json({ error: 'unauthorized' }, 401);Refuse an organization API key before that call (presentsApiKey from ./rpc/api-key-token), so a
key is never handed to Better Auth. A route on an organization’s data uses memberFor from
./rpc/tenancy instead, which does that and checks the caller the way the tenant guards do:
import { memberFor } from './rpc/tenancy';
const access = await memberFor(c.req.raw.headers, organizationId, { permissions: { document: ['read'] }, workspaceId,});if (!access.ok) return c.json({ error: access.error, message: access.message }, access.status);// access.member, access.workspace and access.principal, as the tenant guards hand them onIt answers as the procedures do: 401 without a session, 403 for an organization the caller is not
in or a permission their role lacks, then 404 for a workspace of another tenant or another team.
Its member is the guards’ (memberFromRole), so can, anyTeam and holds agree with theirs and
a visibility rule of yours applies. An organization API key answers 401 unless the route names the
grant it takes keys with (apiKey: { document: ['read'] }); then the key acts as on a procedure
opened to keys, a member with no user, counted against its own limit (429 past it).
auditActor(access) names the key, or the admin behind an impersonated session, for an audit
entry. A database failure is thrown.
A refusal a route throws keeps its status: an ORPCError with a 4xx status answers
{ "error": "<code>", "message": … } with it, so new ORPCError('FORBIDDEN') is a 403
{ "error": "forbidden", "message": "Forbidden" }, and Hono’s HTTPException answers its own
response. Any other error is a 500, logged and reported, with the request id in its body.
A route that changes something and accepts the cookie also runs refuseCrossSiteCookieWrites()
from ./middleware/csrf, which answers a write from a page outside trustedOrigins() with 403.
One whose GET changes something passes { reads: true }, which judges every method, as
/api/v1/* does: a link on another site sends the cookie with a GET. It cannot refuse a link
another application opens, such as a desktop mail client, which the browser sends as it does the
address bar’s, so a GET that changes something is never fully safe from links. Leave it off
where such a link, an email’s included, must reach a read.
Limits
Section titled “Limits”-
Bodies. Every route is held to the API’s default,
DEFAULT_BODY_LIMIT_BYTESinapps/api/src/middleware/body-limit.ts: the largest upload plus 1 MiB.An upload route that takes more declares its prefix in
apps/api/src/product-body-limits.ts:export const productBodyLimits = { '/api/ingest/': 4 * 1024 ** 3 };The API sizes its server from the largest cap and holds every other route to the default. A declared length over the cap is answered 413 before anything is read; a body without one is counted as your route reads it, never held in memory first, and a read past the cap throws
PayloadTooLargeError, which the API answers 413payload_too_largeunless you catch it. Do not put Hono’sbodyLimitin front of a large body: it reads a chunked one into memory first.bun run serve’s proxy reads the same file. The Compose stack’s Caddyfiles cap every/api/*body at 1 MB at the edge, and a drop-in incaddy/conf.dcannot raise it (Caddy nestsrequest_bodycaps and the smaller wins): raise the API’srequest_bodycap in the Caddyfile you deploy. -
Rate. A route that is expensive per request gets a limiter of its own,
rateLimit({ windowMs, max })from./middleware/rate-limit, keyed by client network (an IPv4 address, or an IPv6 address’s /64). A cheap, signed read usually needs none. A limit on a procedure goes inside the procedure instead,spendAllowance(context, rateWindow({ … }))fromrpc/context.ts, ascontact.senddoes: oRPC matches spellings of a path that a Hono route does not (a trailing slash), so a limiter mounted on a procedure’s REST path misses some.
Cache and content headers
Section titled “Cache and content headers”- The API sends
cache-control: private, no-storewith every answer that sets none. A route that serves bytes sets its own:private, max-age=31536000, immutablefor a URL whose content never changes, with anetag, andno-storeon its errors.privatekeeps shared caches out. - Send the exact
content-type:nosniffis on, so the browser will not guess. - Under
Bun.serve,new Response(Bun.file(path))answers aRangerequest with a 206. Nothing else does:app.request()answers the whole file with a 200, and aBun.file().slice()body is never ranged. A route that serves part of a file, or must answer Range in its tests, parsesRangeand answers 206 withcontent-rangeitself. - Hono answers
HEADfor everyGETroute with the GET’s headers and no body. Setcontent-lengthon the GET’s response, or the HEAD says0underBun.serve. Bun.servedrops acontent-lengthyou set on a stream body and sends it chunked;app.request()keeps it. Do not assert a stream’s length in process: test it against a realBun.serveon a free port.- Each response header costs time in
Bun.serve’s write path when the response resolves on a later tick, as every async handler’s does: about 6.5 µs of CPU each. Send the headers a client uses, not every one you can.
Quiet prefixes
Section titled “Quiet prefixes”A path under one of productQuietPathPrefixes is left out of the access log when it succeeds: a
404 or a 500 there is still logged, with its level. It is left out of tracing whatever its status,
because the sampler decides as the request starts. A prefix matches as written, so end it with /:
/api/media/ quiets /api/media/a.jpg and leaves /api/media-admin alone. / alone, a prefix
without a leading /, or one that covers or sits under a kit route (/rpc/, /api/v1/,
/api/auth/, the probes), which would quiet the kit’s own requests, stops the API at start-up
(assertProductPrefix in apps/api/src/route-prefixes.ts). Only the API’s own request spans go
quiet; calls it makes to other services keep theirs.
In the page
Section titled “In the page”The web app’s and the shells’ Content-Security-Policy already admit the API’s origin in img-src
and media-src, so what these routes serve renders in the app on any layout. Another origin, a
tile server or a CDN, goes in productCsp() in apps/web/product-csp.ts.
Test a route with app.request() against the real app, next to the route:
import { describe, expect, it } from 'bun:test';import { app } from '../app';
describe('GET /api/media/:id', () => { it('refuses a request without a valid signature', async () => { const res = await app.request('/api/media/0f3a?exp=1&sig=00'); expect(res.status).toBe(403); expect(res.headers.get('cache-control')).toBe('no-store'); });});app.request() builds the request in process, so it carries no content-length unless you set
one, and no client address: the limiters key it as unknown. Test the refusals first; they are
what the rest of the kit’s tests cannot see.
A route behind a session is tested as a person who signed in. httpTestClient from
apps/api/test/http-client.ts signs one up against the app in process and keeps their cookie:
const client = httpTestClient({ origin: env.API_URL, webOrigin: env.WEB_URL, fetch: app.fetch });const ann = await client.signUp({ email: `${PREFIX}ann@example.invalid`, name: 'Ann', password });const res = await client.fetchAs(ann)('/api/media/0f3a');That writes a user, so it is a database test (*.db.test.ts, claiming PREFIX); without a
database, mock getSession with sessionFor from apps/api/test/sessions.ts.
app.request() is not Bun.serve: Range, HEAD lengths and stream lengths differ (above). A body
you send without a content-length is counted as your route reads it, as a chunked one is in
production.
A test that needs another value of env sets it with overrideEnv or withEnv from
@repo/env/override and puts it back. The app reads your quiet prefixes and other hooks when it
loads, so a test of one imports a fresh copy, await import('../app?quiet-prefixes'), after the
mock.module() it should see (CONTRIBUTING.md, “Style”).
A cache of files you can regenerate
Section titled “A cache of files you can regenerate”Thumbnails, previews and transcodes are derivatives: your product can make each one again from a source it keeps. Keep them in a cache directory of your own and serve them from a route of yours. Stored files are the wrong place: they fsync every object, the storage sweep deletes an object no row references after a day, and their URLs download as attachments, expire in five minutes and are never cached.
- Where. A directory variable of your own in
packages/env/src/product.ts(ACME_CACHE_DIR), optional, with a default under the operating system’s temporary directory in development, and required in production by a rule inproductConfigurationIssues. Never insideSTORAGE_DIR: the backups copy it, and its sweep deletes what no row references. - Layout. One file per derivative,
<root>/v1/<tenant id>/<last two characters of the id>/<id>.<variant>.<generation>.<ext>. The tenant in the path is authorization by construction, since a route that resolved the tenant cannot name another’s file, and removing a tenant’s files is onerm -rf. The last two characters of a time-ordered id (UUIDv7) spread the files evenly, andv1/lets a new layout start empty. - Writes. To a temporary file in the same directory, then
rename, with no fsync: a crash loses at most a file the next request makes again. Check every id against a strict pattern before it becomes part of a path. - Serving. A route of yours under a quiet prefix, with
cache-control: private, max-age=31536000, immutableon a URL that changes with the generation, and anetagfrom it (Cache and content headers). - Size. A recurring task of yours or a sweep as you write deletes the oldest generations past a budget. Nothing else reads the directory.
- Deploys. On the Compose stack the API container’s filesystem is new at every deploy, so mount
a named volume there through a Compose override file of yours, listed in
COMPOSE_FILE(COMPOSE_FILE=infra/compose.yml:infra/compose.cache.yml). Leave it out of backups, and on a Mac out of Time Machine (tmutil addexclusion <dir>) and Spotlight (an empty.metadata_never_indexfile in it). - Tests. Every function takes the root as an argument, so a test passes a temporary directory.