Chambers AppDeveloper documentation

Architecture

The monorepo, the capability executor, access, notices, scheduled work, queues and the platform services.

The shape of the system

One monorepo holds the product. apps/web is the web app: Next.js run on Cloudflare Workers through vinext, serving the member area, the admin area and the API routes from one Worker. packages/core holds the capability executor, every capability and the SQL migrations. apps/docs builds this site. A native app may join later as another client of the same JSON API.

The web app never queries the database. Pages, server actions, route handlers and scheduled jobs all call capabilities through createPlatform(...).run(name, actor, input, operationId), so every permission check, refusal, audit entry and idempotency rule lives in one place and is tested once, at the capability seam, against real SQLite with the real migrations.

A request through the WorkerEvery request except static files (/_next/static/*, /*/_next/static/*, /sw.js, /icons/*), which the assets binding serves directly, reaches worker.ts. API paths draw on the edge limit first; vinext renders pages, runs server actions and route handlers, each of which calls a capability through platform().run(); the Worker adds the security headers to every response it serves. The core reaches D1, R2, email and push, and Workers AI and Vectorize.Installed web apppages, forms, fetchNative applater: JSON APIWorker · worker.tsEdge limitsAPI_LIMIT on /api/* → 429vinextpages · actions · routesSecurity headersCSP with a per-request nonceStatic files skip the Worker.platform().run()name, actor, input,operation idD1R2 · mediaEmail · pushAI · Vectorize
A request through the Worker. Every request except static files (/_next/static/*, /*/_next/static/*, /sw.js, /icons/*), which the assets binding serves directly, reaches worker.ts. API paths draw on the edge limit first; vinext renders pages, runs server actions and route handlers, each of which calls a capability through platform().run(); the Worker adds the security headers to every response it serves. The core reaches D1, R2, email and push, and Workers AI and Vectorize.
PartWhereWhat it does
Worker entryapps/web/worker.tsReceives every request except static files (/_next/static/*, /*/_next/static/*, /sw.js, /icons/*), rate-limits API paths at the edge, hands requests to vinext, adds security headers to every response it serves, runs the scheduled handler for both cron triggers and consumes both queues.
Pages and actionsapps/web/app/[locale]/44 pages, each under its language (see the page map). Forms post to server actions that call capabilities and redirect in the same language.
Route handlersapps/web/app/api/The JSON API, uploads, push subscriptions, check-in at the door, health, and language and chamber choice.
Platform bindingapps/web/lib/platform.tsBuilds the platform from the Worker's bindings: D1, email, R2, Images, Workers AI, Vectorize, the queues and the secrets.
Executorpackages/core/src/platform.tsRuns a capability: operation id, input, access, replay, decide, one atomic batch. Also every piece of scheduled work.
Capabilitiespackages/core/src/capabilities/180 capabilities in the registry: 97 commands, 77 queries and 6 jobs.
Migrationspackages/core/migrations/47 SQL files, 87 tables and views after all of them. Invariants are enforced by triggers.

The capability executor

A capability is a named, typed unit of work with an access rule and a zod input schema. There are three kinds:

Command
Decides on a change. Its decide(ctx, input) reads what it needs and returns a decision: the value to answer with and the SQL writes to apply, plus email to queue in the outbox, email to send once committed, audit detail, and the further units the change concerns. It never writes itself.
Query
A scoped read: read(ctx, input) returns a result. Queries need no operation id, write nothing, and are not audited.
Job
Work that is neither, such as delivering the outbox or fetching news by hand. It needs an operation id and is audited, but its result is not replayed.
How the executor runs a capabilityThe steps of createPlatform(...).run in order: operation id, input, access, replay, then decide. A query reads and returns. A command's writes, its outbox rows, the operation record and the audit entry are applied in one D1 batch; after it commits, secret emails are sent and queued messages delivered.1 · Operation idcommands and jobs,8 to 128 characters2 · Inputzod safeParse→ invalid_input3 · Accesspublic, signed-in,operator, or apermission at a unit4 · Replayoperations row?same: stored resultelse: conflict5 · Decidedecide(ctx, input)→ writes, outbox,emails, audit detailqueryread(ctx, input)result, no writescommandOne D1 batch, all or nothingdecision.writesoutbox rows (email)operations (id, input hash, result)audit_log (+ audit_entry_units)After commitsecret emails, afterCommit, matching,then up to 25 queued messagesAny step may refuse{ code, message }A refused command is still audited,against the unit its access waschecked at.
How the executor runs a capability. The steps of createPlatform(...).run in order: operation id, input, access, replay, then decide. A query reads and returns. A command's writes, its outbox rows, the operation record and the audit entry are applied in one D1 batch; after it commits, secret emails are sent and queued messages delivered.

One batch, because D1 has no interactive transactions

D1 runs a batch of statements atomically but cannot hold a transaction open while code decides. So a command reads first, decides in code, and returns its writes; the executor applies them in one batch together with any outbox rows, the operations row (operation id, actor, a hash of the input and the stored result) and the audit_log entry. Either all of it lands or none of it. Where a race could still break an invariant between the read and the batch, the batch carries guards: triggers that refuse a stale write, and tables whose only purpose is to make a stale batch fail (see the *_guard tables on the data model page). A command maps the constraint a race would hit to a refusal with onConflict.

Idempotency and secret results

Every command and job carries an operation id of 8 to 128 characters (the JSON API takes it from the Idempotency-Key header; forms carry one in a hidden field). Before deciding, the executor looks the id up. The same capability, actor and input hash returns the stored result, so a retried request changes nothing twice. A different request under the same id is refused with operation_conflict. If two identical requests race, the loser's batch fails on the operations primary key and is answered from the winner's record.

A command whose result holds a secret sets secretResult: its result is stored as {}, and repeating its operation id is refused with operation_already_completed rather than replaying the secret. Today: auth.verifyCode, auth.rotateSession, support.viewChamber, export.mine.

Refusals and the audit log

A refusal is { code, message }. The code is stable and is what clients branch on; the message is a developer's English sentence, and the interface shows its own catalogue text for the code instead. The executor's own codes come first: operation_id_required, invalid_input, unauthenticated, forbidden, operation_conflict, operation_already_completed. A target that does not exist is answered forbidden, the same as one the actor may not reach, so nothing is revealed about other chambers. Each capability's own codes are listed in the capability reference.

Every executed and every refused command is written to audit_log with the capability, the actor, the chamber and unit it belongs to, the outcome and the refusal code. Once access has been checked at a unit, a refusal is recorded against that unit so the chamber's admins see it in their log. The log holds ids, never personal details. A change that concerns further units (the committees of a member whose points changed) lists them in audit_entry_units, so each of their admins sees it too.

After the commit

Once the batch commits, the executor sends any email that held a secret (sign-in codes are never stored), runs the decision's best-effort afterCommit (removing R2 objects a change released), dispatches matching work the writes left, and delivers up to 25 of the messages the change queued, email first. The rest, and anything that failed, stay in the outbox for the five-minute schedule. Work done outside the database before the batch (an image written to R2) is undone by abandon if the batch fails.

Access: units, roles and reach

A chamber is a tree of units. The chamber unit is the root; branches sit under it; committees sit either at national level (directly under the chamber) or within a branch. Admin rights are grants of a role at one unit: a chamber admin at the chamber, a branch admin at a branch, an Exco admin at a committee. A grant reaches its unit and everything beneath it, and nothing sideways or above. reaches(db, userId, unitId, permission) walks the unit's lineage to the root and looks for a live grant on it whose role carries the permission.

A scoped capability names a permission and a function at(ctx, input) that finds the unit the permission must be held over (for example the committee of an application). It may also answer "platform" to mean only a platform operator may proceed, or null when the target does not exist. Platform operators are the people whose addresses are listed in the PLATFORM_OPERATOR_EMAILS secret when they sign in; they set up chambers and see across them, and their reach stays in the web admin, never on long-lived API tokens.

What each role may do (scope.ts). 24 permissions.
Permissionchamber_adminbranch_adminexco_admin
unit.viewyesyesyes
unit.createBranchyes
unit.createCommitteeyesyes
unit.renameyesyes
admin.viewyesyesyes
admin.manageyesyes
application.viewyesyesyes
application.decideyesyesyes
member.viewyesyesyes
position.manageyesyesyes
audit.viewyesyesyes
membership.manageyesyes
committee.manageMembersyesyesyes
points.awardyesyesyes
tier.configureyes
visibility.configureyes
data.manageyes
brand.configureyes
event.manageyesyesyes
event.configureyes
digest.configureyes
digest.edityesyes
board.moderateyes
introduction.totalsyesyes

Who may see what

Separately from admin rights, each chamber decides which audiences may see each gateable item: the public, Friends (an application waiting), each tier of its ladder, and office-holders. Every gated read asks canSee, so the chamber's table is the single answer and a change applies on the next read. A suspended member is only the public; the chamber admin sees everything in the chamber. What a new chamber shows:

Visibility defaults (visibility.ts)
ItemPublicFriendsEach tierOffice-holders
member_directoryyesyesyes
member_cardyesyes
committee_excoyesyesyes
past_eventsyesyesyesyes
event_galleryyesyes
news_digestyesyesyes
matchingyesyes

Data requests and erasure

A person may always download a copy of everything held about them (export.mine, a secret result, allowed before accepting the privacy notice) and ask a chamber to delete what it holds (dataRequest.create). Each chamber is the data controller for what it holds and answers within 21 days.

  • Deleting what one chamber holds ends the membership there and replaces the details on the person's applications and imported rows with a placeholder. The chamber's own records (positions, points, history) stay, but no longer say who the person was: wherever a chamber would show the person's email in what it recorded before the deletion, it shows the erased placeholder instead.
  • Erasing the whole account is done by the platform operator once no membership is open anywhere. The email becomes erased-<user id>@erased.invalid, unique and undeliverable, so every record that joins the account reads as a deleted person. Outbox email is never written to an erased address.
  • Registrations, check-ins, photos, matching data, introductions, board posts and push devices are removed with the deletion they belong to. Photos go to the media deletion queue, which the five-minute schedule works off.
  • Backups and D1 Time Travel keep the old rows for up to 30 days; the restore runbook says to redo every deletion completed since the restore point.

Notices, the outbox, email and push

What a person is told is written once, as a notification row holding a kind and its parameters, so it reads in whatever language they use when they open it. notify in notices.ts writes the inbox row and, in the same batch, queues a push to each of the person's devices as the topic's rule allows (most topics unless turned off, opt-in topics only once turned on), so no kind of notice can forget push. Email for a notice is queued in the outbox beside the change that caused it.

Notice topics (notices.ts): one choice per topic and channel
TopicKindsInboxPushEmail
applicationDecidedapplication.approved application.declinedalwaysunless turned offalways (a decision about the person)
applicationReceivedapplication.receivedalwaysunless turned offnever
tierRaisedtier.raisedalwaysunless turned offunless turned off
membershipChangedmembership.suspended membership.reinstated membership.terminated committee.removedalwaysunless turned offalways (a decision about the person)
dataRequestDecideddataRequest.completed dataRequest.refusedalwaysunless turned offalways (a decision about the person)
eventOpenedevent.openedonly if turned ononly if turned ononly if turned on
eventRegistrationevent.registered event.waitlisted event.registrationCancelledalwaysunless turned offunless turned off
waitlistOfferedevent.waitlistOffered event.offerExpiredalwaysunless turned offunless turned off
eventChangedevent.changed event.cancelledalwaysunless turned offunless turned off
eventReminderevent.reminderalwaysunless turned offunless turned off
introductionintroduction.received introduction.accepted introduction.declined introduction.expired introduction.outcomeAskedalwaysunless turned offunless turned off
eventRolesevent.staffNamed event.organisingAwardedalwaysunless turned offnever
boardboard.removedalwaysunless turned offunless turned off

Delivery and retries

An outbox row is claimed (pending or failed to sending) before it is sent, so two deliveries running at once cannot both send it. A failed send is retried by the schedule up to five attempts; a row stuck in sending for ten minutes is released. Errors from the email service are scrubbed of addresses before they are kept or logged. Each email row records the chamber it was sent for, so that chamber deleting the person's data drops it.

Push is Web Push signed with the environment's VAPID key pair. A push row names only the device and the notification; its text is written in the device's language at the moment it is sent. A push to a device whose session has ended, or that now belongs to someone else, is dropped; a device the push service reports gone (404 or 410) is removed with its waiting pushes.

Email layouts

Every email is described once as a layout (email-layout.ts): the chamber's brand for the header and a list of blocks. The plain text is rendered from it when the email is built and stored with the layout in the outbox; the HTML is rendered from the stored layout when it is sent, so a retry sends the same email and the row stays small. Every value is plain text and escaped, so nothing a person typed can become markup, and links are only http(s). A decision about the person carries the same seal as the app, beside who decided and when.

Block kinds: p, facts, code, seal, items, note. Builders in emails.ts: applicationDecidedEmail, membershipChangedEmail, tierRaisedEmail, memberInvitedEmail, dataRequestDecidedEmail, eventChangedEmail, registrationEmail, eventReminderEmail, eventOpenedEmail.

Scheduled work

Two cron triggers call the Worker's scheduled handler, each as its own invocation with its own D1 query budget. scheduledWork(cron, time) in packages/core/src/schedule.ts decides what each run does; every piece is recorded in scheduled_runs (start, end, outcome, counts) and never throws, so one failure does not stop the others. The operator's health page reads those rows.

Scheduled work and the queuesTwo cron triggers, each its own invocation. The five-minute trigger runs the listed work every time and, twice an hour, sends one message per due news source to the news queue, whose consumer fetches and then analyses with Workers AI. The hourly trigger runs only the daily backup to R2. Matching opt-ins are embedded through their own queue into Vectorize. Every run is a row in scheduled_runs.cron */5 * * * *Every run, each recordeddeliverScheduleddeleteMediaScheduledsyncMatchingScheduledassembleDigestsScheduledemailDigestsScheduledmoveWaitlistsScheduledsendEventNoticesScheduledsuggestScheduledexpireIntroductionsScheduledcompleteEventsScheduledaskIntroductionOutcomesScheduledEMBED_QUEUEone message per opt-inqueue(): embedVectorizeat :00 and :30Also, twice an hourfetchNewsScheduledanalyseNewsScheduledNEWS_QUEUEone message per sourcequeue(): fetchWorkers AIcron 15 * * * *Once a UTC daybackupScheduledR2 · BACKUPS
Scheduled work and the queues. Two cron triggers, each its own invocation. The five-minute trigger runs the listed work every time and, twice an hour, sends one message per due news source to the news queue, whose consumer fetches and then analyses with Workers AI. The hourly trigger runs only the daily backup to R2. Matching opt-ins are embedded through their own queue into Vectorize. Every run is a row in scheduled_runs.
The cron triggers (apps/web/cloudflare.config.ts) and the work each runs
CronRuns
*/5 * * * *outbox, digest, waitlists, eventNotices, suggestions, introductions, eventCompletion, introductionOutcomes; at minute 00 and 30 also news
15 * * * *backup
Read from the scheduled handler in apps/web/worker.ts
WorkPlatform callsWhat it does
outboxdeliverScheduledRetries email that could not be sent when a decision was made.
outboxdeleteMediaScheduledRemoves media objects queued for deletion: replaced or removed photos, and the photos of people whose data was erased.
outboxsyncMatchingScheduledMatching upkeep alongside: deletes vectors still waiting to go, and sends again opt-ins whose queue message was lost.
backupbackupScheduledBacks up the database once a UTC day, alone on its own trigger; on the other hourly runs it finds nothing due.
digestassembleDigestsScheduled then emailDigestsScheduledPuts together each chamber's news digests once their ready time has passed today (catching up a missed run), and removes those older than 90 days; on the other runs it finds nothing due. Then emails the digest to the members who chose it and are due (daily, or weekly on Monday), a bounded number per run; the rest go next run.
waitlistsmoveWaitlistsScheduledExpires places offered from event waitlists after their 24 hours and offers each freed place to the next in line.
eventNoticessendEventNoticesScheduledReminds everyone holding a place about a day before an event, and tells those who want it when an event's registration opens to them; each once, a bounded number per run, the rest on the next.
suggestionssuggestScheduledMakes the week's matching suggestions, from Monday, for a few opted-in members per run; once everyone has theirs it finds no one due.
introductionsexpireIntroductionsScheduledExpires introduction requests not answered within 14 days, telling each asker, a bounded number per run.
eventCompletioncompleteEventsScheduledCompletes events a day past their end: their named staff's rights end and who attended is fixed.
introductionOutcomesaskIntroductionOutcomesScheduledAsks each person of an introduction accepted 30 days ago, once, whether business came of it, a bounded number per run.
newsfetchNewsScheduled then analyseNewsScheduledFetches news on every sixth five-minute run, at :00 and :30. Each source's own minimum wait still applies. When deployed, this only sends one message per due source to the news queue and the queue handler below fetches them. Then classifies, summarises and translates waiting items, a bounded number per run; the rest wait for the next fetch.

Recorded task names in scheduled_runs: outbox, backup, news.fetch, news.analyse, news.digest, events.waitlists, events.notices, introductions.expire, events.complete, introductions.outcomes, news.email, media.delete, matching, matching.suggestions. The backup claims its UTC day first, so it runs once a day and retries an hour after a failure.

Queues

Two Cloudflare Queues are bound when deployed (local development binds neither, and the same work runs inline). The Worker's queue handler tells them apart by name.

Queue consumers (cloudflare.config.ts)
QueueBatchRetriesConcurrencyWhat a message is
embed10 per batch, 5 s wait5, 60 s apartdefaultOne matching opt-in and the profile version to embed. A stale message is acknowledged without work; a failed model or store call is retried.
news5 per batch, 5 s wait3, 60 s apart1One due news source to fetch. The consumer claims the source first, so a repeated message fetches nothing; after each batch it runs the analysis.

Workers AI and Vectorize

News analysis classifies, summarises and translates headlines with @cf/meta/llama-3.3-70b-instruct-fp8-fast, using only the headline and the feed's own description; without the binding (local development) keyword rules classify and no text is made. Business matching embeds a member's offers and needs with @cf/baai/bge-m3 into 1024 dimensions and stores them in a Vectorize index with cosine distance, one namespace per chamber, filtered on an indexed kind metadata field. Weekly suggestions pair one member's needs with others' offers; the language model writes the reason shown with each.

R2: media and backups

Two private R2 buckets. MEDIA holds uploaded images under opaque ids: profile photos at 96, 256, 512 px squares, event photos at 256, 1024, 2048 px wide and chamber logos at 128 and 512 px tall, all WebP with metadata removed, resized by Cloudflare Images. Uploads go through the Worker, never to presigned URLs: the route checks origin and session before reading the body (the member import action, /api/uploads/brand-logo and /api/uploads/event-photo also take one upload from the network's upload allowance; profile photos are limited only by the general API allowance), then reads at most 5 MB (1 MB for a logo), and the platform checks the type from the bytes. Images are served through routes that apply the viewer's rights on every request.

BACKUPS receives the daily logical backup: every table as JSON Lines under backups/<date>/ with a manifest written last, kept 30 days. It complements D1 Time Travel; see Operations.

Rate limits, KV and Turnstile

Abuse controls are counted per network (an IPv4 address or an IPv6 /64) with Cloudflare's rate limiting bindings and a KV namespace. Their exact thresholds are kept in the repository (apps/web/lib/security/limits.ts), not published here; what a client sees is the refusal: 429 rate_limited or 429 upload_rate_limited with Retry-After: 60, or 403 challenge_required.

Rate limiting bindings
BindingApplied to
API_LIMITEvery request whose routed path starts with /api, per network, at the edge in worker.ts, except /api/health (polled by monitoring, and reads nothing private). Answered 429 rate_limited with Retry-After.
SIGN_IN_NETWORK_LIMITSign-in checks per network (a sign-in takes two: request and verify).
SIGN_IN_ADDRESS_LIMITSign-in checks per email address from one network.
UPLOAD_LIMITUploads per network, taken by the member import action, /api/uploads/brand-logo and /api/uploads/event-photo, and by import.prepare on the JSON API. Profile photos do not take from it; they are limited by API_LIMIT only.

The SIGN_IN_ATTEMPTS KV namespace counts sign-in attempts per network over a short window. After a few failures, or a run of code requests of any outcome, the network must pass a Turnstile challenge (challenge_required), whose site key comes with the refusal. The core adds its own limits on codes per address and guesses per code, and a code lives 10 minutes. A session lasts 30 days and its token is swapped for a new one (auth.rotateSession) once it is a day old.

Security headers and the content security policy

The Worker sets these on every response it serves, redirects and server actions included. Inline scripts carry a fresh nonce per request: the Worker passes it to vinext in the request, vinext puts it on every script it writes, and the response policy names the same nonce. Static files that skip the Worker (/_next/static/*, /*/_next/static/*, /sw.js, /icons/*) get their headers from apps/web/public/_headers.

default-src 'self';
script-src 'self' 'nonce-{nonce}' https://challenges.cloudflare.com;
style-src 'self' 'nonce-{nonce}';
style-src-attr 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-src https://challenges.cloudflare.com;
worker-src 'self';
manifest-src 'self';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests
HeaderValue
permissions-policycamera=(self), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()
referrer-policystrict-origin-when-cross-origin
strict-transport-securitymax-age=31536000; includeSubDomains
x-content-type-optionsnosniff
x-frame-optionsDENY

Languages

The interface ships in Chinese (zh) and English (en). Every page lives under its language (/zh/..., /en/...); an address without one is redirected by a route (no middleware: under this vinext and Cloudflare setup it did not run in development) to the visitor's saved choice in the locale cookie, else their browser's preference, else Chinese.

All interface text comes from catalogues in apps/web/messages/en/ and zh/: 35 JSON files per language, one per feature, composed in each index.ts. English is the source catalogue, and a test fails if the languages differ in keys or placeholders. Messages use ICU syntax, so plurals and selects live in the message. Dates and numbers are formatted in the chamber's time zone, Asia/Kuala_Lumpur. A refusal code is shown through errors.<code>. The privacy notice is always shown in Malay and English, whatever the interface language.

Chambers and brands by address

One Worker serves every chamber. The address a request arrives at is looked up in chamber_hosts, which the platform operator maintains; the chamber's brand (name in both languages, a one-character mark, one colour checked for contrast against white text, and an optional logo) is applied to every page, the web app manifest and every email. An address mapped to no chamber shows the neutral brand. A person may belong to several chambers under one login and switches between them.

The page map

Every page of the web app, from apps/web/app/[locale]/, with the capabilities the page and its server actions run. {locale} is zh or en.

Member area

Admin area

Admin area pages
AddressCapabilities run
/{locale}/adminchamber.list dataRequest.listAll host.list
/{locale}/admin/{chamberId}admin.list board.moderation brand.settings dataRequest.listChamber digest.settings introduction.totals position.listCommittee unit.detail unit.tree
/{locale}/admin/{chamberId}/brandbrand.removeLogo brand.settings brand.update host.remove host.set
/{locale}/admin/{chamberId}/eventsevent.adminList event.cancel event.configureWaves event.create event.publish event.setWaves event.update event.wavePattern unit.tree
/{locale}/admin/{chamberId}/events/{eventId}checkIn.list event.adminDetail eventAttendance.report eventOrganiser.list eventPhoto.gallery eventStaff.list registration.adminList
/{locale}/admin/{chamberId}/importimport.prepare import.remap import.run import.targets import.view
/{locale}/admin/{chamberId}/introductionsintroduction.totals
/{locale}/admin/{chamberId}/logaudit.listChamber unit.tree
/{locale}/admin/{chamberId}/membersmember.list unit.tree
/{locale}/admin/{chamberId}/members/{membershipId}member.detail
/{locale}/admin/{chamberId}/newsdigest.assemble digest.configure digest.settings digest.withdraw
/{locale}/admin/{chamberId}/requestsdataRequest.listChamber unit.tree
/{locale}/admin/{chamberId}/tierspoints.settings tier.ladder
/{locale}/admin/{chamberId}/visibilityvisibility.table
/{locale}/admin/applicationsapplication.queue unit.tree
/{locale}/admin/healthoperations.health
/{locale}/admin/newsnews.recentItems news.sources

Sign-in and consent

Sign-in and consent pages
AddressCapabilities run
/{locale}/consentnone of its own
/{locale}/sign-innone of its own
/{locale}/sign-in/verifynone of its own

Other pages

Other pages pages
AddressCapabilities run
/{locale}/{...rest}none of its own
/{locale}/offlinenone of its own

Bindings

What the production Worker is bound to, from apps/web/cloudflare.config.ts. Resource ids and account details are not published here.

BindingTypeResourceWhen bound
ASSETSassetsevery environment
DBd1databaseevery environment
EMAILsend-emailevery environment
BACKUPSr2backupsevery environment
MEDIAr2mediaevery environment
IMAGESimagesevery environment
AUTH_SECRETsecretevery environment
APP_URLtextevery environment
EMAIL_FROMsecretevery environment
PLATFORM_OPERATOR_EMAILSsecretevery environment
TURNSTILE_SITE_KEYsecretevery environment
TURNSTILE_SECRET_KEYsecretevery environment
VAPID_PUBLIC_KEYsecretevery environment
VAPID_PRIVATE_KEYsecretevery environment
VAPID_SUBJECTsecretevery environment
SIGN_IN_NETWORK_LIMITrate-limitevery environment
SIGN_IN_ADDRESS_LIMITrate-limitevery environment
API_LIMITrate-limitevery environment
UPLOAD_LIMITrate-limitevery environment
SIGN_IN_ATTEMPTSkvevery environment
AIaistaging and production only
VECTORSvectorizematchingstaging and production only
EMBED_QUEUEqueueembedstaging and production only
NEWS_QUEUEqueuenewsstaging and production only

Observability: the Worker writes structured JSON log lines, scrubbed of emails, phone numbers and tokens in core (scrub.ts), and invocation logs to Workers Logs.