Backend contract
Every backend preset under backends/ is a standalone, runnable service that the
dashboard frontend (this repo) reaches through its two seams — Repository (data)
and AuthProvider (auth) — without changing any page, query, table, or form.
To make a single frontend wiring serve multiple stacks, the HTTP-API presets
(hono-drizzle-betterauth, fastapi-sqlalchemy-jwt) all speak the same wire
contract defined here. The supabase preset is the exception: it is consumed
through Supabase's own PostgREST + Auth surface and ships its own thin adapter — see
backends/supabase/README.md.
This file is the single source of truth for that contract. A preset that diverges from
it will not wire up cleanly, and its verification (backends/<preset> test project)
asserts against these exact shapes.
0. The sample resource: products
Every preset ships one canonical resource, products, mirroring the frontend's
src/features/products exactly so the wiring maps one-to-one. A product record:
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) | server-assigned, stable |
name |
string, non-empty | searchable |
sku |
string, non-empty | searchable |
category |
string, non-empty | searchable, sortable |
price |
number ≥ 0 | sortable |
stock |
integer ≥ 0 | sortable |
status |
enum: available | out_of_stock | discontinued |
filterable (exact match) |
description |
string (may be empty) | |
createdAt |
string (ISO-8601, UTC) | sortable, default sort key (desc) |
updatedAt |
string (ISO-8601, UTC) | bumped on every write |
ProductInput (create / update body) = all of the above except the four
server-owned fields (id, createdAt, updatedAt). description is optional and
defaults to "". The server validates input and rejects unknown/invalid fields.
Whitelists (mirror the frontend; never sort or filter by raw input):
- Searchable (
qmatches case-insensitively, OR across):name,sku,category. - Sortable (
_sort):name,category,price,stock,createdAt. Anything else → fall back to the default sort. - Filterable (exact match):
status. - Default sort:
createdAtdescending.
1. Data API — json-server dialect (Repository via restRepository)
The frontend binds restRepository with its
default query-param names, so backends MUST speak the json-server dialect verbatim.
Base path is the collection, e.g. /products.
GET /products — list
Query params (all optional except none are required):
| Param | Meaning | Example |
|---|---|---|
_page |
1-based page number | _page=2 |
_limit |
page size | _limit=10 |
_sort |
sort field (from whitelist) | _sort=price |
_order |
asc | desc |
_order=desc |
q |
full-text search | q=widget |
status |
exact-match filter | status=available |
- Body:
200with a JSON array of the matching page of product objects. - Header:
X-Total-Count: <n>— the count of the filtered, not paginated set.restRepositoryreads this to drive pagination. It MUST be present. - Unknown sort fields fall back to the default sort; unknown filter keys are ignored.
- CORS (only needed if a browser hits the backend directly — the frontend proxies
server-side, so this is belt-and-suspenders): expose the count header with
Access-Control-Expose-Headers: X-Total-Count.
GET /products/:id — read one
200 product object, or 404 if absent (the frontend maps 404 → null).
POST /products — create
Body = ProductInput. 201 (or 200) with the created product (including id).
400 on validation failure.
PATCH /products/:id — update
Body = partial ProductInput. 200 with the full updated product. 404 if absent.
DELETE /products/:id — delete
200 or 204. 404 if absent.
The frontend never sends auth on these data calls in the default wiring (the fetch runs inside a server fn that has already called
requireUser()); the API is reached over a trusted server-to-server hop. All HTTP-API presets support an optional bearer guard on data routes via the standardDATA_API_TOKENenv var: when set, the frontend forwards it throughrestRepository'sheaders({ Authorization: 'Bearer <DATA_API_TOKEN>' }) and the backend rejects requests without it; when unset, the data routes are open and MUST NOT be publicly exposed (keep them on the trusted server-to-server hop).Per-preset exception (validation status): the
fastapi-sqlalchemy-jwtpreset returns422(not400) on validation failure — this is FastAPI/Pydantic's native behaviour and an accepted exception to the documented400.
2. Auth API
Every preset normalizes to the frontend's AuthSession ({ user: { id, email, name } })
behind an AuthProvider. There are three supported auth shapes; a preset implements
exactly one, and ships the matching frontend wiring (AuthProvider + auth-client).
| Shape | Preset(s) | Frontend wiring |
|---|---|---|
2a. Custom JWT (/auth/*, bearer) |
fastapi-sqlalchemy-jwt |
external-JWT AuthProvider + custom auth-client |
2b. Remote better-auth (/api/auth/*, cookies) |
hono-drizzle-betterauth, hono-prisma-betterauth |
remote-better-auth AuthProvider + better-auth client |
2c. Remote Auth.js (/api/auth/*, CSRF + cookies) |
hono-drizzle-authjs |
remote-authjs AuthProvider + custom auth-client |
Why three shapes: Python has no better-auth, so the FastAPI preset rolls a small, explicit JWT auth (2a). The better-auth presets host real better-auth (2b) — same SDK the in-process default uses. The Auth.js preset hosts Auth.js (NextAuth) v5 (2c) with a Credentials provider — a different cookie/CSRF protocol, wired by its own remote proxy. All three resolve to the same normalized session.
2a. Custom JWT (fastapi-sqlalchemy-jwt)
Stateless HS256 JWTs; secret AUTH_JWT_SECRET. Claims: sub = user id, email,
name, iat, exp (default 7 days).
POST /auth/register— body{ email, password, name }→201 { token, user }.409if email exists,400on validation.POST /auth/login— body{ email, password }→200 { token, user }.401on bad credentials. Passwords stored hashed (bcrypt/argon2 — never plaintext).GET /auth/me— headerAuthorization: Bearer <token>→200 { id, email, name }, else401. This is whatgetSessioncalls on every guarded load.POST /auth/logout—200 { ok: true }(stateless; the frontend clears its cookie).
Cookie handoff: the browser only talks to the frontend origin (/api/auth/*).
The frontend's AuthProvider.handler forwards to {AUTH_API_URL}/auth/*, reads
{ token, user }, and sets the session cookie on the frontend origin:
So this backend returns the token in the JSON body, not as a cookie — the frontend
owns the cookie. getSession reads session=<token> and validates it via GET /auth/me.
2b. Remote better-auth (hono-drizzle-betterauth)
The service hosts better-auth (email + password, Drizzle adapter) at /api/auth/* — the
exact same protocol as the in-process default, just on a standalone origin. Endpoints
are better-auth's own (/api/auth/sign-in/email, /sign-up/email, /sign-out,
/get-session), cookie-based.
The frontend wiring is a thin remote adapter, not a re-implementation:
auth-client=createAuthClient({ baseURL: <service-origin> })— samesignIn/signUp/signOut/useSessionsurface, calls land on the service.AuthProvider.getSession=GET {service}/api/auth/get-sessionforwarding the request cookies; normalize the result to{ user }.AuthProvider.handler= proxy/api/auth/*to{service}/api/auth/*, forwarding method/headers/body and rewriting upstreamSet-Cookieto host-only (drop theDomainattribute) so the session cookie lands on the frontend origin.
The service must set trustedOrigins to include the frontend origin (CORS + CSRF), and
its better-auth baseURL/secret follow the same fail-closed-in-prod rule as §3.
hono-prisma-betterauthimplements this exact shape too — only the service's ORM differs (Prisma instead of Drizzle), so it reuses the same frontend wiring.
2c. Remote Auth.js / NextAuth (hono-drizzle-authjs)
The service hosts Auth.js (NextAuth) v5 with a Credentials provider (JWT session
strategy — Credentials requires it) at /api/auth/*, plus a custom POST /api/auth/register
(Auth.js has no sign-up endpoint). Endpoints are Auth.js's own conventions:
GET /api/auth/csrf→{ csrfToken }.POST /api/auth/callback/credentials(body:csrfToken,email,password) → sets the Auth.js session cookie. This is the sign-in.GET /api/auth/session→{ user: { id?, name, email } }when authed, else{}.POST /api/auth/signout(withcsrfToken) → clears the session cookie.POST /api/auth/register(custom; body{ name, email, password }) → creates the user (bcrypt-hashed) so the next sign-in succeeds.409if the email exists.
The frontend wiring is a remote Auth.js proxy (pure fetch, no Auth.js SDK on the
frontend) + a small auth-client that drives the CSRF→callback dance:
auth-client.signIn.email=GET /api/auth/csrfthenPOST /api/auth/callback/credentials.signUp.email=POST /api/auth/registerthen sign-in.signOut= csrf +signout.useSession= thegetSessionserver fn.AuthProvider.getSession=GET {service}/api/auth/sessionforwarding cookies; normalize{ user }(synthesize a stableidfrom the email if Auth.js omits it on a JWT session).AuthProvider.handler= proxy/api/auth/*to the service, rewritingSet-Cookieto host-only.
AUTH_SECRET (Auth.js) follows the same fail-closed-in-prod rule; the service sets
trustedOrigins/trustHost for the frontend origin.
3. Zero-config posture (mirror the frontend)
Each HTTP-API preset MUST boot with no external infrastructure, exactly like the
frontend's zero-config bun dev:
- No
DATABASE_URL→ SQLite file (or in-memory) so the service runs on a clean checkout with one install + one run command. The bundled test project relies on this. DATABASE_URLset → Postgres (the production path).- No
AUTH_JWT_SECRETin dev → a clearly-labelled insecure dev fallback; in production the service MUST fail closed (refuse to boot) if the secret is unset, the same waysrc/lib/auth.tsdoes.
4. Verification obligation
Every preset directory ships its own test suite that asserts this contract against the real service (no mocks of its own transport):
register→login→GET /auth/meround-trips a user.POST /productsthenGET /products?_page=1&_limit=10returns the row and a correctX-Total-Count.- Search (
q), filter (status), sort (_sort/_order), and pagination each work. PATCHthenGET /products/:idreflects the change;DELETEthenGET→ 404.
The repo-level verification (the backends/<preset> "test project" runs) stands each
service up for real and exercises this list end-to-end. See each preset's README.