Skip to content

API Contracts

The HTTP API is a SvelteKit app on Cloudflare Workers. The cardinal rule, repeated everywhere in this handbook: route handlers in apps/web/src/routes/api/ are thin wrappers; the real logic lives in apps/web/src/lib/server/api/. A handler parses and validates input, calls an API-module function, and serialises the result. If you are writing more than ~40 lines in a +server.ts, you are probably putting logic in the wrong place.

Sessions are two HttpOnly cookies set by setSessionCookies:

  • jwt — a short-lived (15 min, sameSite=lax) HS256 JWT (hand-rolled over node:crypto, not a library) carrying { sub: userDid, iat, exp }. hooks.server.ts verifies signature + expiry and sets event.locals.user. Verification is stateless — a logged-out or revoked user keeps a usable JWT until it expires (≤15 min); an accepted trade-off.

    Audit note — why hand-rolled. The token is HS256-only with no alg negotiation: createHmac('sha256', secret) to sign, a constant-time timingSafeEqual to verify, and that is the entire surface. Fixing the algorithm sidesteps the algorithm-confusion bug class that general JWT libraries carry (alg: none, RS256↔HS256 key confusion) and keeps the Workers bundle lean. The cost is that the sign/verify code is ours to keep correct, so it carries dedicated tests — including the iat-in-the-future forgery guard (60 s skew tolerance) added after review (see DECISION_LOG.md). If you touch server/auth/session.ts, keep it single-algorithm and keep those tests green.

  • refresh_token — a 30-day (sameSite=strict) opaque token (the session UUID). When the JWT expires the hook rotates it: OAuth2-style, the old session row is CAS-revoked and a new one minted, with a 60 s reuse-grace window for concurrent refreshes; presenting a genuinely revoked token outside that window purges every session sharing its familyId (theft response).

Routes don’t check cookies by hand. They use guard helpers from api/guard.ts:

// Authenticated route:
authedHandler(event, async (userDid) => { ... });
// → 401 { error: "Unauthorised" } when logged out
// Instance-admin route:
adminHandler(event, env.INSTANCE_ADMIN_ID, async (adminDid) => { ... });
// → 401/403 otherwise

Group-level authorisation (member / admin) happens inside the API module via the membership helpers (assertMember, admin checks), not in the route. DEV_SKIP_AUTH=true in .dev.vars bypasses auth for local/e2e work (localhost only — see Architecture).

Two layers:

  1. hooks.server.ts rejects any non-safe-method request to /api/* whose Origin header host ≠ request host (a lenient host-only check; 403). An Origin-less request passes the hook and relies on the per-route check.
  2. Mutating routes call requireSameOrigin(request, url) (→ isSameOrigin, server/auth/origin-check.ts) explicitly — a scheme-strict full-origin comparison that rejects a missing Origin. This is the real gate (and the missing-Origin gate); the hooks layer is the cheap first pass. Scheme-strict is safe because BrightBlur is a single-origin Workers deployment.

Failures are thrown as typed errors (api/errors.ts) and mapped to status codes; the response body is always { error: string }. Unhandled exceptions become a generic 500 (logged server-side, never echoed to the client).

Class Status Class Status
BadRequestError 400 ConflictError 409
Unauthorised (guard) 401 GoneError 410
ForbiddenError 403 (payload too large) 413
NotFoundError 404 (rate limited) 429
  • JSON bodies: parseBody(request, schema) (valibot) → BadRequestError on bad JSON or schema failure.
  • /api/photos is multipart/form-data, parsed manually in photo-upload.ts; JSON sub-fields via parseJsonField.
  • Every Uint8Array crosses the wire as standard base64 (api/encoding.ts encodeBase64/decodeBase64), decoded at the route boundary before it reaches Drizzle.
  • parseLimitParam(url, { max }) uses parseInt, falls back to the caller’s default for absent/non-positive/non-integer values, and clamps to [1, max]. Garbage → default is deliberate.

hooks.server.ts enforces a 1 MB JSON cap on mutating /api/* (20 MB for /api/photos), then a fixed-window per-IP rate limiter (D1-backed, atomic UPSERT…RETURNING in rate-limiter.ts). Some routes add a second per-email or per-user limit inside the handler.

Scope Route Limit / window
per-IP POST /api/auth/login 10 / 15 min
per-IP POST /api/auth/register 5 / 1 h
per-IP POST /api/auth/passkey/login 20 / 15 min
per-IP POST /api/auth/password-reset 5 / 1 h
per-IP GET /api/search 60 / 1 min
per-IP POST /api/reports 10 / 1 h
per-email POST /api/auth/login 10 / 15 min
per-email POST /api/auth/register 5 / 1 h
per-user POST /api/photos/[id]/comments 30 / 5 min
per-user POST /api/groups/[id]/members 20 / 1 h

Auth requirement key: Public · Session (any logged-in user) · Member/Admin (of the group in the path) · Instance-admin · Cron (cron-secret).

Method Path Purpose Auth
POST /api/auth/login Password login Public
POST /api/auth/register Email+password registration Public
POST /api/auth/logout Destroy session Session
GET /api/auth/refresh Refresh JWT Public
GET /api/auth/email/verify Verify email via token Public
POST /api/auth/email/resend Resend verification Session
POST /api/auth/password-reset/request · /verify Reset flow (request always 200) Public
POST /api/auth/register/passkey-options · /passkey Passkey-only registration Public
POST /api/auth/passkey/login/options · /verify Passkey login ceremony Public
GET/POST /api/auth/passkey/credentials List / add passkey Session
PATCH/DELETE /api/auth/passkey/credentials/[id] Rename / delete passkey Session
Method Path Purpose Auth
POST /api/photos Upload (multipart) Session
GET /api/photos/[id] Photo + adjacent nav Member
PATCH/DELETE /api/photos/[id] Update caption / delete Owner
GET/POST /api/photos/[id]/comments List / post comments Member
GET /api/feed Main feed (keyset) Session
GET /api/users/[did]/photos · /api/people/[id]/photos Photos by user / of a person Session
GET /api/people/[id]/photos/[photoId] Untag a person from a photo Admin
GET /api/memories/today “On this day” feed Session
GET /api/blobs/[...key] Download an encrypted R2 blob Session
Method Path Purpose Auth
GET/POST /api/groups List / create (person) groups Session
GET/PATCH/DELETE /api/groups/[id] Get / rename / delete Member / Admin
GET/POST/DELETE /api/groups/[id]/members[/did] List / add / remove members Member / Admin
GET/POST/DELETE /api/groups/[id]/admins[/did] List / promote / demote Member / Admin
GET /api/groups/[id]/public-key Current epoch public key Member
POST /api/groups/[id]/invites Create invite link Admin
POST /api/groups/[id]/leave · /complete-departure Leave / finalise departure (rotates) Member / Admin
GET /api/groups/[id]/face-slices · /deletion-impact List slices / deletion impact Member / Admin
POST /api/groups/[id]/transfer Transfer ownership Admin
POST /api/invite/accept Accept an invite Session
POST/GET /api/key-rotation[...] Initiate / track / complete rotation Admin / Session
POST/GET /api/merges[...] Person-group merge lifecycle Admin / Session
Method Path Purpose Auth
GET/POST /api/person-embeddings List / store embeddings Member
DELETE /api/person-embeddings/[id] Delete embedding Admin
GET /api/person-embeddings/unincorporated · /mine Pending embeddings Member / Session
GET/POST /api/person-pools[/id] Get / upsert pool (CAS) Session / Admin
GET/PUT /api/personal-negatives Get / upsert personal negatives (CAS) Session
GET/POST /api/face-slices/unembedded · /[id]/reject Rebuild source / reject slice Session
Method Path Purpose Auth
GET/POST /api/notifications[...] List / mark read / read-all / count Session
GET/POST /api/access-requests[...] List / request / approve / reject / ignore Session / Admin
GET/PATCH/DELETE /api/users/me[...] Profile, password, export, deletion, encryption key Session
GET /api/users/[did]/public-key · /api/users/resolve Public key / email→DID Session
POST/GET /api/reports[...] Submit / review / action reports Session / Instance-admin
POST /api/push/subscribe · /test Web Push Session
GET /api/search User/group search Session
GET /api/health · /api/instance-admin/public-key Health / admin key Public
POST /api/cron/* Session cleanup, challenge cleanup, blob reconcile, on-this-day, data retention Cron

A few flows you will touch early — request → response, with the field names that matter. (Base64 columns marked b64.)

POST /api/photos (multipart). Fields: baseImage (File, ≤20 MB encrypted), thumbImage? (File), circles (JSON [{ circleGroupId, wrappedContentKey: b64 }], 1–100), faceSliceMeta (JSON [{ personGroupId, boundingBox, epochGeneration }], ≤200) with matching faceSlice_0…N File blobs, intersectionsMeta + intersection_0…N for joint-access slices, caption? (b64 ciphertext), aspectRatio?, idempotencyKey?. → 201 { id, … }. A JSON POST with { epochGeneration, circleGroupIds } is the stale-epoch probe → 409 { error: "Stale epoch generation…" } if any group has rotated past that generation.

These field names are not string literals scattered across the codebase — they are exported from @brightblur/wire (packages/wire/src/index.ts) and shared by the web publish pipeline, the mobile publish path, and the server multipart parser (faceSlice_0…N come from faceSlicePart(i), intersection_0…N from intersectionPart(i)). Rename a field there, not here.

GET /api/feed?cursor=&limit= → { photos: FeedPhoto[], nextCursor: string|null }. FeedPhoto:

{ id, ownerDid, ownerName, caption, baseImageKey, thumbImageKey, aspectRatio,
createdAt, commentCount,
circles: [{ circleGroupId, circleName, wrappedContentKey: b64 }],
faceSlices:[ { id, personGroupId, personName, personDid, boundingBox,
encryptedSlice: b64, epochGeneration, accessible: true }
| { id, boundingBox, accessible: false,
requestStatus: 'pending'|'rejected'|null } ],
intersections: [{ groupAId, groupAEpoch, groupBId, groupBEpoch, boundingBox,
encryptedBlob: b64, wrappedHalfA: b64, wrappedHalfB: b64,
viewerCanDecrypt: boolean }],
batchSize?: number }

canViewPhoto is the single visibility gate: active circle membership only — being the owner is not sufficient (reports are the one place that checks isUploader first).

GET /api/photos/[id]/comments → { comments: [{ id, photoId, authorDid, bodyCiphertext: b64, createdAt, authorName }], nextCursor }. authorName is server-resolved. POST body { bodyCiphertext: b64 } → the created row (comments are encrypted client-side).

POST /api/groups { name, epochPublicKey: b64, wrappedPrivateKey: b64, isSelfGroup? } → 201 group. Every group is a person-group; the type column was dropped (migration 0049), so there is no separate circle-creation flow. GET /api/groups returns each group with its wrappedKeys: [{ generation, wrappedPrivateKey: b64 }].

POST /api/person-embeddings { personGroupId, faceSliceId, photoId, encryptedEmbedding: b64, epochGeneration } → 201 { id }. GET /api/person-pools rows include version (the row’s updatedAt in Unix seconds) — the CAS token for the next write.

GET /api/blobs/[...key] → raw application/octet-stream, Cache-Control: private, max-age=31536000, immutable, ETag. Honours If-None-Match → 304, but re-authorises access on every request including 304.

These patterns recur; learn them once.

  • Photo upload idempotency. A repeated multipart POST /api/photos with the same (owner, idempotencyKey) returns the already-committed photo rather than inserting a duplicate. Safe to retry after a network failure — and the stale-epoch JSON probe writes nothing, so a 409 leaves no state.
  • Stale-epoch retry. On a 409 stale-epoch, the client refreshes keys, re-encrypts the affected circle wraps, and retries the multipart upload with the same idempotency key.
  • Challenge CAS. Passkey login/registration challenges are consumed with a guarded DELETE…RETURNING, so exactly one concurrent verifier proceeds and the rest fail closed before any CBOR/COSE work. This closes a registration-replay hole (ticket bri-1516).
  • Pool / negative-store CAS. POST /api/person-pools/[id] and PUT /api/personal-negatives accept expectedVersion: undefined = blind upsert, null = expect no row, a number = compare-and-set against updatedAt; mismatch → 409. Version stamps strictly advance (max(now, current+1)) to dodge same-second collisions.
  • Wrapped-key upserts. wrappedKeyUpsertStatements (groups.ts) is the single (groupId, generation, userDid) upsert shape reused by add-member, add-admin, transfer, rotation, merge, and access-approval — all batched atomically.

Cursors. Every list endpoint — the feed, comments, reports, and the per-person photo lists — uses one self-describing keyset format: base64url(JSON { createdAt, id }), via api/keyset-cursor.ts. Because the cursor carries its own (createdAt, id), there is no anchor-photo lookup and a deleted anchor no longer breaks pagination.

The keyset wire format is frozen; Number.isFinite guards reject crafted 1e999 cursors, and a malformed cursor falls back silently to “from the beginning”.

hooks.server.ts (CSRF, body caps, rate limits, session), api/guard.ts (auth guards), api/errors.ts (error hierarchy), api/validation.ts (parseBody, parseLimitParam), api/feed.ts (getFeed/getPhoto/canViewPhoto + the FeedPhoto shape), api/photo-upload.ts (upload schemas), api/auth.ts + api/passkey.ts (auth ceremonies), api/person-data.ts (embeddings/pools + CAS), api/groups.ts (membership + wrapped-key upserts), rate-limiter.ts.