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.
The shape of every request
Section titled “The shape of every request”Auth and sessions
Section titled “Auth and sessions”Sessions are two HttpOnly cookies set by setSessionCookies:
jwt— a short-lived (15 min,sameSite=lax) HS256 JWT (hand-rolled overnode:crypto, not a library) carrying{ sub: userDid, sid, exp }.hooks.server.tsverifies signature + expiry and setsevent.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 itsfamilyId(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 otherwiseGroup-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:
hooks.server.tsrejects any non-safe-method request to/api/*whoseOriginheader host ≠ request host (a lenient host-only check; 403). AnOrigin-less request passes the hook and relies on the per-route check.- Mutating routes call
requireSameOrigin(request, url)(→isSameOrigin,server/auth/origin-check.ts) explicitly — a scheme-strict full-origin comparison that rejects a missingOrigin. 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.
Errors
Section titled “Errors”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 |
Body parsing and binary fields
Section titled “Body parsing and binary fields”- JSON bodies:
parseBody(request, schema)(valibot) →BadRequestErroron bad JSON or schema failure. /api/photosismultipart/form-data, parsed manually inphoto-upload.ts; JSON sub-fields viaparseJsonField.- Every
Uint8Arraycrosses the wire as standard base64 (api/encoding.tsencodeBase64/decodeBase64), decoded at the route boundary before it reaches Drizzle. parseLimitParam(url, { max })usesparseInt, falls back to the caller’s default for absent/non-positive/non-integer values, and clamps to[1, max]. Garbage → default is deliberate.
Body-size caps and rate limits
Section titled “Body-size caps and rate limits”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 |
Endpoint inventory
Section titled “Endpoint inventory”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 |
Photos & feed
Section titled “Photos & feed”| 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 |
Groups, membership, keys
Section titled “Groups, membership, keys”| 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 |
Recognition data
Section titled “Recognition data”| 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-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-negatives | Get / upsert personal negatives (CAS) | Session |
| GET/POST | /api/face-slices/unembedded · /[id]/reject | Rebuild source / reject slice | Session |
Social, account, moderation, ops
Section titled “Social, account, moderation, ops”| 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 | Cron |
Core contracts
Section titled “Core contracts”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.
Idempotency, CAS, and pagination
Section titled “Idempotency, CAS, and pagination”These patterns recur; learn them once.
- Photo upload idempotency. A repeated multipart
POST /api/photoswith 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]andPUT /api/personal-negativesacceptexpectedVersion:undefined= blind upsert,null= expect no row, a number = compare-and-set againstupdatedAt; 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:
| Endpoints | Cursor format |
|---|---|
GET /api/feed | a 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 lists | base64url(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”.
Where to look
Section titled “Where to look”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.