Skip to content

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.

apps/api/src/product-http.ts
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.

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, which clientIp from ./middleware/rate-limit reads, the tracing span, the access log, the security headers (nosniff, a default-src 'none' policy on the response, a cross-origin Cross-Origin-Resource-Policy so a page on the app’s origin can show what the API serves), the cache-control default, CORS for the origins in trustedOrigins(), and the API’s body cap: the default, or the cap your prefix declares in product-body-limits.ts (Limits).
  • Not in front of yours: the procedure limiter and the 1 MiB body cap that cover /rpc/* and /api/v1/*, the X-CSRF-Token check 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.

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 in packages/env/src/product.ts, listed in productSecretNames), and compare with timingSafeEqual from node:crypto.
  • Put them in the query string as exp and sig. Tracing keeps no query string on any span, and both Caddyfiles delete sig from 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 on

It 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.

  • Bodies. Every route is held to the API’s default, DEFAULT_BODY_LIMIT_BYTES in apps/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 413 payload_too_large unless you catch it. Do not put Hono’s bodyLimit in 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 in caddy/conf.d cannot raise it (Caddy nests request_body caps and the smaller wins): raise the API’s request_body cap 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({ … })) from rpc/context.ts, as contact.send does: 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.

  • The API sends cache-control: private, no-store with every answer that sets none. A route that serves bytes sets its own: private, max-age=31536000, immutable for a URL whose content never changes, with an etag, and no-store on its errors. private keeps shared caches out.
  • Send the exact content-type: nosniff is on, so the browser will not guess.
  • Under Bun.serve, new Response(Bun.file(path)) answers a Range request with a 206. Nothing else does: app.request() answers the whole file with a 200, and a Bun.file().slice() body is never ranged. A route that serves part of a file, or must answer Range in its tests, parses Range and answers 206 with content-range itself.
  • Hono answers HEAD for every GET route with the GET’s headers and no body. Set content-length on the GET’s response, or the HEAD says 0 under Bun.serve.
  • Bun.serve drops a content-length you 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 real Bun.serve on 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.

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.

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:

apps/api/src/media/route.test.ts
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”).

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 in productConfigurationIssues. Never inside STORAGE_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 one rm -rf. The last two characters of a time-ordered id (UUIDv7) spread the files evenly, and v1/ 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, immutable on a URL that changes with the generation, and an etag from 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_index file in it).
  • Tests. Every function takes the root as an argument, so a test passes a temporary directory.