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, sid, 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.
  • 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).

ClassStatusClassStatus
BadRequestError400ConflictError409
Unauthorised (guard)401GoneError410
ForbiddenError403(payload too large)413
NotFoundError404(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.

ScopeRouteLimit / window
per-IPPOST /api/auth/login10 / 15 min
per-IPPOST /api/auth/register5 / 1 h
per-IPPOST /api/auth/passkey/login20 / 15 min
per-IPPOST /api/auth/password-reset5 / 1 h
per-IPGET /api/search60 / 1 min
per-IPPOST /api/reports10 / 1 h
per-emailPOST /api/auth/login10 / 15 min
per-emailPOST /api/auth/register5 / 1 h
per-userPOST /api/photos/[id]/comments30 / 5 min
per-userPOST /api/groups/[id]/members20 / 1 h

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

MethodPathPurposeAuth
POST/api/auth/loginPassword loginPublic
POST/api/auth/registerEmail+password registrationPublic
POST/api/auth/logoutDestroy sessionSession
GET/api/auth/refreshRefresh JWTPublic
GET/api/auth/email/verifyVerify email via tokenPublic
POST/api/auth/email/resendResend verificationSession
POST/api/auth/password-reset/request · /verifyReset flow (request always 200)Public
POST/api/auth/register/passkey-options · /passkeyPasskey-only registrationPublic
POST/api/auth/passkey/login/options · /verifyPasskey login ceremonyPublic
GET/POST/api/auth/passkey/credentialsList / add passkeySession
PATCH/DELETE/api/auth/passkey/credentials/[id]Rename / delete passkeySession
MethodPathPurposeAuth
POST/api/photosUpload (multipart)Session
GET/api/photos/[id]Photo + adjacent navMember
PATCH/DELETE/api/photos/[id]Update caption / deleteOwner
GET/POST/api/photos/[id]/commentsList / post commentsMember
GET/api/feedMain feed (keyset)Session
GET/api/users/[did]/photos · /api/people/[id]/photosPhotos by user / of a personSession
GET/api/people/[id]/photos/[photoId]Untag a person from a photoAdmin
GET/api/memories/today”On this day” feedSession
GET/api/blobs/[...key]Download an encrypted R2 blobSession
MethodPathPurposeAuth
GET/POST/api/groupsList / create (person) groupsSession
GET/PATCH/DELETE/api/groups/[id]Get / rename / deleteMember / Admin
GET/POST/DELETE/api/groups/[id]/members[/did]List / add / remove membersMember / Admin
GET/POST/DELETE/api/groups/[id]/admins[/did]List / promote / demoteMember / Admin
GET/api/groups/[id]/public-keyCurrent epoch public keyMember
POST/api/groups/[id]/invitesCreate invite linkAdmin
POST/api/groups/[id]/leave · /complete-departureLeave / finalise departure (rotates)Member / Admin
GET/api/groups/[id]/face-slices · /deletion-impactList slices / deletion impactMember / Admin
POST/api/groups/[id]/transferTransfer ownershipAdmin
POST/api/invite/acceptAccept an inviteSession
POST/GET/api/key-rotation[...]Initiate / track / complete rotationAdmin / Session
POST/GET/api/merges[...]Person-group merge lifecycleAdmin / Session
MethodPathPurposeAuth
GET/POST/api/person-embeddingsList / store embeddingsMember
DELETE/api/person-embeddings/[id]Delete embeddingAdmin
GET/api/person-embeddings/unincorporated · /minePending embeddingsMember / Session
GET/POST/api/person-centroids[/id]Get / upsert centroid (+mark incorporated)Session / Admin
GET/POST/api/person-pools[/id]Get / upsert pool (CAS)Session / Admin
GET/PUT/api/personal-negativesGet / upsert personal negatives (CAS)Session
GET/POST/api/face-slices/unembedded · /[id]/rejectRebuild source / reject sliceSession
MethodPathPurposeAuth
GET/POST/api/notifications[...]List / mark read / read-all / countSession
GET/POST/api/access-requests[...]List / request / approve / reject / ignoreSession / Admin
GET/PATCH/DELETE/api/users/me[...]Profile, password, export, deletion, encryption keySession
GET/api/users/[did]/public-key · /api/users/resolvePublic key / email→DIDSession
POST/GET/api/reports[...]Submit / review / action reportsSession / Instance-admin
POST/api/push/subscribe · /testWeb PushSession
GET/api/searchUser/group searchSession
GET/api/health · /api/instance-admin/public-keyHealth / admin keyPublic
POST/api/cron/*Session cleanup, challenge cleanup, blob reconcile, on-this-dayCron

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.

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

{ id, ownerDid, ownerName, caption, baseImageKey, thumbImageKey, aspectRatio,
createdAt, commentCount,
circles: [{ circleGroupId, circleName, groupType, 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. All API-created groups are type: "person"; circles are created through the dedicated audience 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. Two formats coexist:

EndpointsCursor format
GET /api/feeda plain photo-ID string; the server derives the (createdAt, id) keyset from it, and returns 410 Gone if that photo was deleted (stop paginating)
comments, reports, other listsbase64url(JSON { createdAt, id }), via api/keyset-cursor.ts

The keyset wire format is frozen; Number.isFinite guards reject crafted 1e999 cursors, and malformed cursors on non-feed lists fall 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/centroids + CAS), api/groups.ts (membership + wrapped-key upserts), rate-limiter.ts.