Chambers AppDeveloper documentation

JSON API

Calling capabilities over HTTP, and the other routes.

This page starts with docs/api.md, the API's own guide, as it stands in the repository. The reference after it is generated from the handler (apps/web/lib/api/handler.ts), the list of what is offered (apps/web/lib/api/exposed.ts) and the route files, so it cannot disagree with them; a test checks the guide's status table against the handler.

The backend's public surface for a native app (or any client that is not the web app's own pages). It runs the same capabilities the pages run, through the same executor, so every permission, refusal, audit entry and idempotency rule is the same.

  • Route: apps/web/app/api/v1/capabilities/[name]/route.ts, handling in apps/web/lib/api/handler.ts.
  • Which capabilities are offered: apps/web/lib/api/exposed.ts. A test fails when a new capability is neither offered nor kept off with a reason.
  • Tests: apps/web/lib/api/handler.test.ts (fake platform) and apps/web/lib/api/flow.test.ts (the real platform over SQLite: sign-in, apply and approve through the route's handlers).

Calling a capability

POST /api/v1/capabilities/<name>
Content-Type: application/json
Authorization: Bearer <session token>      (once signed in)
Idempotency-Key: <8 to 128 characters>     (for a command)

<the capability's input, as a JSON object>

A query (a read) that takes no input may also be fetched with GET /api/v1/capabilities/<name>. Input never goes in the URL: request URLs are kept in Workers invocation logs and in proxy and client logs, so a GET with ?input= is refused with 400 api_input_in_url, and a query with input is sent by POST like a command (without an Idempotency-Key). GET on a command is refused with 405 and Allow: POST. An empty POST body means {}.

Every answer is JSON and is sent with Cache-Control: no-store:

{ "ok": true, "value": { "...": "..." } }
{ "ok": false, "refusal": { "code": "already_applied", "message": "…" } }

The message is the catalogue's text for the code (the API's own wording from apiErrors in apps/web/messages/*/api.json where the page's wording would not fit an app, else the shared errors text), in the language of the Accept-Language header (Chinese when neither language is asked for). Show message to the person and branch on code. Inputs and values are as defined in each capability in packages/core/src/capabilities/.

Status codes

StatusCodes
200the capability ran
400invalid_input, operation_id_required (no Idempotency-Key on a command), api_body_invalid, api_input_in_url
401unauthenticated (sign in), api_session_invalid (the bearer token is unknown, revoked, expired or malformed)
403forbidden, challenge_required, api_cross_origin, api_consent_required, api_operator_web_only
404not_found, api_unknown_capability (not a capability, or not offered on the API)
405api_method_not_allowed
409operation_conflict (the key was used for a different request), operation_already_completed (a repeated secret-holding command)
422every other refusal: the capability's own rule, such as already_applied or branch_required
429rate_limited, upload_rate_limited (wait the Retry-After seconds, or a minute)

Who is asking

  • Bearer token: Authorization: Bearer <token>, the token from auth.verifyCode. No origin check is made: a browser never sends this header on its own, so another site cannot borrow it. A bearer header that is present must be valid; a lapsed one is answered 401 api_session_invalid, never treated as a visitor.
  • Session cookie: the web app's own session cookie, for the app's pages. A cookie request must come from this app's own origin: an Origin header naming another origin, or Sec-Fetch-Site: cross-site, is refused with 403 api_cross_origin, the same check the other API routes make. A stale cookie counts as no session.
  • Neither: a visitor; public capabilities run, others answer 401 unauthenticated.
  • Platform operators are turned away. A session belonging to an operator (bearer or cookie) is refused with 403 api_operator_web_only for everything except auth.signOut, and when an operator signs in through auth.verifyCode the new session is revoked at once and no token is returned. Many member and admin capabilities give an operator platform-wide reach (completing an account erasure, appointing admins of any chamber, branding, moderation); that reach stays in the web admin.

The API never sets or clears a cookie, and sends no CORS headers: it is for native apps and the app's own pages, so a page on another origin cannot read its answers. (In pnpm dev, vinext's own development origin check answers a cross-origin request with a plain 403 Forbidden before the API sees it.)

The privacy notice

As on the pages, a signed-in person must have accepted the current privacy notice before member and admin capabilities run. Until then they are refused with 403 api_consent_required, and the refusal carries the version to accept:

{ "ok": false, "refusal": { "code": "api_consent_required", "message": "…", "noticeVersion": "2026-11-draft-4",
  "notice": { "ms": { "language": "Bahasa Malaysia", "title": "…", "paragraphs": ["…"] },
              "en": { "language": "English", "title": "…", "paragraphs": ["…"] } } } }

Show notice (both languages, whatever the interface language, as on /zh/consent), then call consent.accept with {"locale": "zh"|"en"}. The server records acceptance of the version it serves; a noticeVersion the app sends is ignored, so an old build cannot record a version that was never shown. Allowed before acceptance: auth.*, consent.accept, dataRequest.create, dataRequest.listMine, export.mine, and every public capability. auth.whoami returns consentedNoticeVersion.

Idempotency

A command (or job) needs an Idempotency-Key, which becomes the operation id. Generate one per intended action (a UUID) and reuse it when retrying that action after a timeout: the retry is answered with the recorded result and nothing happens twice. The same key with a different input or person is refused with 409 operation_conflict. A missing key is 400 operation_id_required. Queries ignore the header.

Commands whose result holds a secret (auth.verifyCode, auth.rotateSession, export.mine) never store their result: a retry under the same key is refused with 409 operation_already_completed and never returns the token again. If that happens after a lost response, start again with a new code (or, for rotation, sign in again).

Signing in

  1. Ask for a code. The email carries the brand of the address the API is called on.

    POST /api/v1/capabilities/auth.requestCode
    Idempotency-Key: 6f1c…
    {"email": "mei@example.com", "locale": "zh"}
    → 200 {"ok": true, "value": {"sent": true}}
  2. Check the code; the value is the session token, to keep in the device's secure storage.

    POST /api/v1/capabilities/auth.verifyCode
    Idempotency-Key: 0b9e…
    {"email": "mei@example.com", "code": "123456"}
    → 200 {"ok": true, "value": {"token": "…"}}
  3. Who am I (also tells the app when to rotate):

    GET /api/v1/capabilities/auth.whoami
    Authorization: Bearer <token>
    → 200 {"ok": true, "value": {"userId": "…", "email": "mei@example.com", "consentedNoticeVersion": null, "rotationDue": false, …}}

    auth.whoami and auth.rotateSession take their token from the bearer header when the body leaves it out. Never put a token in a URL.

  4. When rotationDue is true, swap the token: POST auth.rotateSession {} with the current token as the bearer header returns {"token": "<new>"}, and the old one stops working.

  5. Sign out: POST auth.signOut {"everywhere": false} with the bearer token revokes this device's session (and its push subscriptions); {"everywhere": true} revokes every session. The token then answers 401 api_session_invalid.

Sign-in is guarded as the sign-in page is (apps/web/lib/security/limits.ts): per network and per address each minute, and, once a network has made several attempts in fifteen minutes, a Turnstile challenge. Without a token the call is refused with 403 challenge_required, and the refusal carries the Turnstile siteKey. The app shows the Turnstile widget with that key in a WebView whose page is loaded with the app's host as its base URL (the widget only runs on hostnames the site key allows), takes the token the widget's callback gives, and repeats the call with it in the CF-Turnstile-Response header. The core still limits codes per address and guesses per code.

Applying and approving

POST /api/v1/capabilities/consent.accept
Authorization: Bearer <token>
Idempotency-Key: …
{"locale": "en"}

GET  /api/v1/capabilities/committee.listOpen
POST /api/v1/capabilities/application.submit
{"committeeId": "…", "branchId": "…", "fullName": "Tan Mei Ling", "phone": "+60 12-345 6789",
 "company": "Sample Sdn Bhd", "position": "Director", "industry": "Trading", "locale": "en"}
→ 200 {"ok": true, "value": {"id": "…", "status": "submitted", …}}

The committee's Exco admin (or a branch or chamber admin above it):

POST /api/v1/capabilities/application.queue     {"unitId": "<committee id>"}
POST /api/v1/capabilities/application.approve   {"applicationId": "…", "note": "Welcome."}
POST /api/v1/capabilities/application.decline   {"applicationId": "…", "note": "…"}

Anyone else is answered 403 forbidden.

Which capabilities are offered

Every member and admin capability, and sign-in, for anyone who is not a platform operator (operator sessions are refused, above). Kept off, with the reason in exposed.ts:

  • Platform-operator capabilities (chamber.create, chamber.list, audit.list, operations.health, news.* sources and jobs, host.*, dataRequest.listAll, support.viewChamber, outbox.deliver, digest.assemble, digest.sendEmails, eventNotice.send): operator work stays in the web admin, where support access asks for and shows its reason. Operator sessions are kept off long-lived app tokens by refusing them, not by this list alone.
  • Raw image bytes (profile.setPhoto, brand.setLogo, eventPhoto.upload, and the readers profile.photo, brand.logo, eventPhoto.file, eventPhoto.showcaseFile): JSON cannot carry them. Uploads go through /api/uploads/* and photos are served from /photos/…, /event-photos/… and /showcase-photos/…. These use the session cookie today; a native app needs bearer support added there.

Limits

  • Every call draws on the edge's per-network allowance, API_LIMIT (worker.ts), answered 429 rate_limited with Retry-After: 60.
  • auth.requestCode and auth.verifyCode are guarded as above.
  • import.prepare, which takes a CSV file, also draws on the network's upload allowance (UPLOAD_LIMIT), answered 429 upload_rate_limited.

Examples

API=https://chambers.example.com/api/v1/capabilities

curl -s "$API/auth.requestCode" -H 'content-type: application/json' \
  -H "idempotency-key: $(uuidgen)" -d '{"email":"mei@example.com","locale":"en"}'

TOKEN=$(curl -s "$API/auth.verifyCode" -H 'content-type: application/json' \
  -H "idempotency-key: $(uuidgen)" -d '{"email":"mei@example.com","code":"123456"}' | jq -r .value.token)

curl -s "$API/membership.mine" -H "authorization: Bearer $TOKEN" -H 'accept-language: zh'

curl -s "$API/auth.signOut" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -H "idempotency-key: $(uuidgen)" -d '{"everywhere":false}'

Worked examples

API is the capabilities address, such as https://chambers.example.com/api/v1/capabilities (a chamber's own address; production is served at chambers.whymelabs.com), and TOKEN the session token from auth.verifyCode.

A query with input, by POST

Input never travels in the URL. A query that takes input is sent by POST, without an Idempotency-Key.

curl -s "$API/event.view" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"eventId":"<event id>"}'

Retrying a command safely

Make one key per intended action and reuse it on retry. The second call is answered from the operation record; nothing happens twice.

KEY=$(uuidgen)
curl -s "$API/registration.register" -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -H "idempotency-key: $KEY" -d '{"eventId":"<event id>","guests":[],"locale":"en"}'
# The connection dropped? Send the same request with the same key:
curl -s "$API/registration.register" -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -H "idempotency-key: $KEY" -d '{"eventId":"<event id>","guests":[],"locale":"en"}'

A member capability called before the current notice is accepted answers 403 with the notice to show:

curl -s "$API/membership.mine" -H "authorization: Bearer $TOKEN"
# → {"ok":false,"refusal":{"code":"api_consent_required","message":"Read and accept the privacy notice before continuing.","noticeVersion":"…","notice":{…}}}

curl -s "$API/consent.accept" -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -H "idempotency-key: $(uuidgen)" -d '{"locale":"en"}'

An error, as a client sees it

HTTP/1.1 422
cache-control: no-store
content-type: application/json

{"ok":false,"refusal":{"code":"already_applied","message":"You already have an application waiting for this committee."}}

Status codes, from the handler

statusFor in apps/web/lib/api/handler.ts
StatusCodes
400invalid_input operation_id_required api_body_invalid api_input_in_url
401unauthenticated api_session_invalid
403forbidden challenge_required api_cross_origin api_consent_required api_operator_web_only
404not_found api_unknown_capability
405api_method_not_allowed
409operation_conflict operation_already_completed
422Every other refusal: the capability's own rule
429rate_limited upload_rate_limited

The API's refusal codes and messages

The English text an app receives for each general code (Chinese is sent when the request asks for it). A capability's own codes, with their messages, are listed with each capability in the reference.

CodeStatusMessage
invalid_input400Check what you entered and try again.
operation_id_required400Send a new Idempotency-Key header with this action, then try again.
api_body_invalid400The request could not be read. Send its details as a JSON object.
api_input_in_url400The request's details cannot travel in its address. Send them in the body instead.
unauthenticated401Sign in to continue.
api_session_invalid401Your sign-in has ended. Sign in again to continue.
forbidden403You are not allowed to do this.
challenge_required403Complete the check to show you are a person, then try again.
api_cross_origin403This request did not come from the app, so it was not carried out.
api_consent_required403Read and accept the privacy notice before continuing.
api_operator_web_only403Platform operators work in the web admin. Sign in there instead.
not_found404That could not be found.
api_unknown_capability404There is no such action in the app's interface.
api_method_not_allowed405This action changes something, so it must be sent, not fetched.
operation_conflict409This Idempotency-Key was already used for a different request. Send a new one with these details.
operation_already_completed409This was already done, and its result is not kept. Start again.
rate_limited429Too many attempts from your network or for this address. Wait a minute, then try again.
upload_rate_limited429Too many uploads from your network. Wait a minute, then try again.

Offered capabilities

154 capabilities, every member and admin capability and sign-in. A query may be fetched by GET when it takes no input; every other call is a POST.

Queries are listed plainly; everything else needs an Idempotency-Key
DomainCapabilities
Sign-in and sessionsauth.requestCode, auth.verifyCode, auth.whoami, auth.rotateSession, auth.signOut, consent.accept
Structure and adminschamber.listMine, unit.createBranch, unit.createCommittee, unit.rename, unit.tree, unit.detail, admin.appoint, admin.revoke, admin.list
Systemsystem.health
Applications and membershipcommittee.listOpen, application.submit, application.withdraw, application.listMine, application.queue, application.approve, application.decline, membership.mine, inbox.list, inbox.markRead
Membersmember.list, member.detail
Chamber log and support accessaudit.listChamber
Positionsposition.record, position.end, position.listCommittee, position.listMember, position.mine
Membership lifecyclecommittee.leave, membership.resign, committee.removeMember, membership.suspend, membership.reinstate, membership.terminate, membership.allowedChanges
Points and tierstier.ladder, tier.configure, tier.set, points.settings, points.configure, points.award, points.correct, points.history, points.mine
Member importimport.targets, import.prepare, import.remap, import.view, import.run
Visibilityvisibility.table, visibility.configure, visibility.mine
Data requests and erasureexport.mine, dataRequest.create, dataRequest.listMine, dataRequest.listChamber, dataRequest.complete, dataRequest.refuse
Brands and addressesbrand.resolve, brand.settings, brand.update
Brand logosbrand.removeLogo
Eventsevent.create, event.update, event.publish, event.cancel, event.setWaves, event.configureWaves, event.wavePattern, event.adminList, event.adminDetail, event.upcoming, event.view
Profilesprofile.mine, profile.save, profile.card
Profile photosprofile.removePhoto
News digestdigest.settings, digest.configure, digest.pin, digest.hide, digest.restore, digest.add, digest.announce, digest.withdraw, digest.forViewer
Push and notice preferencespush.subscribe, push.refresh, push.unsubscribe, push.listMine, notice.preferences, notice.setPreferences
Event registrationsregistration.register, registration.cancel, registration.accept, registration.forEvent, registration.mine, registration.adminList
Member directorydirectory.search, directory.card
Business matchingmatching.status, matching.optIn, matching.optOut
Digest emaildigest.emailPreference, digest.setEmail
Check-incheckIn.card, checkIn.venue, checkIn.list, checkIn.scan, checkIn.manual, checkIn.walkIn, checkIn.self, checkIn.undo
Check-in door copycheckIn.doorCopy
Event photoseventPhoto.approve, eventPhoto.reject, eventPhoto.addToShowcase, eventPhoto.removeFromShowcase, eventPhoto.caption, eventPhoto.remove, eventPhoto.gallery, eventPhoto.showcase
Matching suggestionssuggestion.list, suggestion.refresh, suggestion.dismiss
Introductionsintroduction.request, introduction.accept, introduction.decline, introduction.mine, introduction.with
Event staff and attendanceeventStaff.name, eventStaff.remove, eventStaff.list, eventStaff.mine, eventAttendance.complete, eventAttendance.report, eventOrganiser.award, eventOrganiser.remove, eventOrganiser.list
Asks and offers boardboard.post, board.close, board.remove, board.browse, board.moderation, board.mine
Introduction outcomesintroduction.answerOutcome, introduction.outcomesMine, introduction.totals

Kept off the API

CapabilityWhy
chamber.createPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
chamber.listPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
audit.listPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
outbox.deliverPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
support.viewChamberPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
operations.healthPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
news.sourcesPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
news.addSourcePlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
news.updateSourcePlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
news.recentItemsPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
news.fetchPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
dataRequest.listAllPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
host.listPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
host.setPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
host.removePlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
brand.setLogoCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
brand.logoCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
news.analysePlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
profile.setPhotoCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
profile.photoCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
digest.assemblePlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
digest.sendEmailsPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.
eventPhoto.uploadCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
eventPhoto.fileCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
eventPhoto.showcaseFileCarries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses.
eventNotice.sendPlatform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens.

Every route under /api

Besides the capability endpoint, these routes serve the web app's own pages; the JSON API's bearer tokens do not reach them yet. Who each serves is read from what its handler checks: public routes check neither the session nor the origin; cookie routes read the session cookie and answer by the viewer's rights; the uploads, push, check-in and export routes also refuse a request from another origin.

RouteMethodsAccessWhat it does
/api/auth/refreshPOSTReads the session cookie
API_LIMIT

Swaps the session token for a fresh one. Called by the page when the session says rotation is due.

apps/web/app/api/auth/refresh/route.ts

/api/chamberGETPublic
API_LIMIT

Saves which chamber the user is looking at and returns them to the same page. The choice is checked against their chambers on every request, so nothing is trusted here.

apps/web/app/api/chamber/route.ts

/api/check-inGET POSTSession cookie, same origin only
API_LIMIT

Check-in for the pages at the door, which work from a phone that may drop its connection:

GET ?what=card&chamber=… the member's card code, fetched afresh before it runs out.
GET ?what=list&event=… the door list, for event staff.
GET ?what=venue&event=… the venue codes a staff screen shows.
GET ?what=copy&event=… the door copy a staff phone keeps to match cards offline (core door-copy.ts).
POST { action, key, … } a check-in (scan, walkIn, manual, self), sent once or replayed from the device's queue under the same key; or an undo. A staff check-in is answered with the door list as it now stands.

Only for this app's own pages and the signed-in person; never stored by the browser or the service worker.

apps/web/app/api/check-in/route.ts

/api/exportPOSTSession cookie, same origin only
API_LIMIT

Downloads a copy of everything held about the signed-in person, as one JSON file. A POST from the "Your data" form, so a link or prefetch never makes an export; each export is a new entry in the platform's log. Each click is a new operation, since the copy is never stored for replay. Like a deletion request, it does not wait for the privacy notice: a person may always see what is held about them. A failure returns the member to a page in their language that says what went wrong.

apps/web/app/api/export/route.ts

/api/healthGETPublic
no edge limit

Runs system.health with no actor and answers 200 with the database's state, or 503 when it cannot be reached. The deploy workflow's smoke test calls it.

apps/web/app/api/health/route.ts

/api/localeGETPublic
API_LIMIT

Saves the visitor's language choice and shows them the same page in it.

apps/web/app/api/locale/route.ts

/api/pushPOST PATCH DELETESession cookie, same origin only
API_LIMIT

This device's push subscription, sent by the page once the person allows notifications (POST), refreshed with the language and session they are using as the app is used (PATCH), or removed when they turn push off on this device (DELETE). Only from this app's own pages, for the signed-in person.

apps/web/app/api/push/route.ts

/api/uploads/brand-logoPOSTSession cookie, same origin only
API_LIMIT, UPLOAD_LIMIT

A chamber's new logo, posted by the plain form on its brand page as multipart (`logo`, with the chamber id, operation id and language), sent through the Worker rather than to R2 on a presigned address (see docs/operations/deploy.md), and answered by going back to the brand page.

Nothing of the body is read before the origin, the session and the network's upload allowance (UPLOAD_LIMIT) are checked, and then only up to the limit, whatever length the request declares. The platform checks who may change the chamber's logo, and the logo's type and size from its bytes.

apps/web/app/api/uploads/brand-logo/route.ts

/api/uploads/event-photoPOSTSession cookie, same origin only
API_LIMIT, UPLOAD_LIMIT

A photo for an event, posted by the gallery's form (multipart, so it works without script) with the event, an operation id, the page's language and the page to return to. It goes through the Worker, not to R2 on a presigned address (see packages/core/src/media.ts): the Worker reads at most the limit, whatever length the request declares, and the platform checks the bytes, not the name or declared type, and whether the person may add photos to this event.

Each upload takes one from the network's per-minute allowance (UPLOAD_LIMIT), checked before the body is read.

apps/web/app/api/uploads/event-photo/route.ts

/api/uploads/profile-photoPOSTSession cookie, same origin only
API_LIMIT

The member's new profile photo, sent through the Worker (not to R2 on a presigned address; see docs/operations/deploy.md). The edge limits uploads per network before the request gets here.

The page's crop panel posts the cropped image as the raw body (`content-type: image/jpeg`) with its operation id and language in headers, and is answered in JSON. Without script, the same form posts the chosen file as multipart (its language in the address) and is sent back to the profile page. Either way the platform checks the bytes, not the name or the declared type.

Nothing of the body is read before the origin and the session are checked, and then only up to the limit, whatever length the request declares: a larger body is answered photo_too_large.

apps/web/app/api/uploads/profile-photo/route.ts

/api/v1/capabilities/{name}GET POSTBearer token, or the session cookie from the same origin
API_LIMIT, UPLOAD_LIMIT

The JSON API for a native app (docs/api.md): POST runs an offered capability, GET runs a query. The handling lives in lib/api/handler.ts, free of Workers APIs; this binds it to the Worker.

apps/web/app/api/v1/capabilities/[name]/route.ts

Other routes

Routes outside /api: images served with the viewer's rights checked on every request, the chamber's logo and icon, the manifest, and the bare address.

RouteMethodsAccessWhat it does
/GETPublic

The bare address sends the visitor to the home page in their language.

apps/web/app/route.ts

/{locale}/manifest.webmanifestGETPublic

The installable app's manifest in the page's language, branded for the chamber the address is mapped to.

apps/web/app/[locale]/manifest.webmanifest/route.ts

/brand-icon.pngGETPublic

The PNG icon of the chamber this address shows, or of the neutral brand, drawn like the SVG (app/brand-icon.svg): its mark in white inside a thin white rule, on its colour. For the installed app on a platform that takes only a PNG, and the apple-touch-icon.

apps/web/app/brand-icon.png/route.tsx

/brand-icon.svgGETPublic

The icon of the chamber this address shows, or of the neutral brand: the favicon and the installed app's icon.

apps/web/app/brand-icon.svg/route.ts

/brand-logoGETPublic

One height of a chamber's uploaded logo, from the MEDIA bucket (see lib/brand-logo.ts). A logo is public, like the rest of the brand.

- `/brand-logo?id=<media id>&h=128`, as pages load it: the media id changes with every upload, so the response may be cached anywhere for good. A replaced or removed logo, or any id that is not a chamber's current logo, is not found. - `/brand-logo?chamber=<chamber id>&h=128`, as emails load it: whatever the chamber's logo is now, so an email already delivered keeps its header after the logo is replaced. Cached for an hour.

apps/web/app/brand-logo/route.ts

/event-photos/{photoId}/{width}GETReads the session cookie

One width of an event photo, for the signed-in viewer, by the gallery's rules (eventPhoto.file): the uploader and the event's admins always; others only an approved photo, where the chamber shows event galleries to them. A photo waiting or rejected is never sent to other members. Anything not to be shown is answered as not found.

Private and revalidated on every use, like profile photos: a browser may keep its copy but asks again each time, so a photo rejected or taken down stops being shown. No shared cache holds it.

apps/web/app/event-photos/[photoId]/[width]/route.ts

/photos/{userId}/{size}GETReads the session cookie

One size of a person's photo, for the signed-in viewer. The member's own needs no chamber; anyone else's is served only by the rules of their card in the chamber asked about (profile.photo), so a hidden photo is never sent to other members. Anything not to be shown is answered as not found.

The response is private and revalidated on every use: a browser may keep its copy, but asks again each time, and a viewer who may no longer see the photo gets nothing. No shared cache holds it.

apps/web/app/photos/[userId]/[size]/route.ts

/showcase-photos/{photoId}/{width}GETReads the session cookie

One width of a photo picked for the showcase of past events (eventPhoto.showcaseFile). Only showcase photos from events that have ended are served here, to anyone the chamber shows past events to, signed in or not.

The showcase is the only place photos may be kept by a shared cache: when the chamber shows past events to the public, the response is public for a few minutes, keyed by its address, which names the photo's media id and width and nothing about the viewer, so the cookie is not read. Otherwise it is private and revalidated on every use, like a member's photo.

apps/web/app/showcase-photos/[photoId]/[width]/route.ts

The pages themselves are listed in the page map.