Configuration
apps/core-api runs on Cloudflare Workers and is configured through bindings and environment values. This page covers the full set and the deploy targets.
The distinction matters more than it looks:
- Bindings — the database, the R2 bucket, the queue, the KV namespace, the rate-limit Durable Object — are declared in
apps/core-api/wrangler.jsoncand authorised by being bound. There is no R2 key or service-account credential anywhere in that file, and a binding a deployment doesn’t attach is a missing capability rather than a broken credential. - Values are either non-secret
varsin the same file, or Worker secrets set out of band withwrangler secret put. Everything below marked “store as a secret” is the second kind. Locally, both live inapps/core-api/.dev.vars(gitignored) — see the quick start.
Under a Node host the same values are read from process.env, so nothing here is Workers-only syntax.
Core variables
Section titled “Core variables”| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string for records, transcripts, processing state, and idempotency keys. Required — the composition root throws rather than falling back. The SQL client runs @neondatabase/serverless over HTTP, so this must be a host that serves Neon’s HTTP SQL endpoint; Neon’s pooled (-pooler) host is the right one, since the driver is stateless per query. Store as a secret. |
ANTIPHONY_PUBLIC_BASE_URL |
Required. The absolute base URL this deployment answers on, e.g. https://api.antiphony.dev. The audio proxy streams bytes rather than redirecting, so AudioEmbedView.url is an absolute URL pointing back here — without this, a post with audio hydrates with no embed at all. |
ANTIPHONY_R2_BUCKET |
Name of the bucket behind the BLOBS binding. Used for logging and for the rendition service’s own addressing, not for authorisation. |
TRUSTED_PROXY_HOPS |
Number of proxy hops to trust when deriving the client IP from X-Forwarded-For. Only the fallback rate-limit key uses the client IP now (see below), but a value that doesn’t match your actual proxy depth still mis-attributes it. |
LOG_LEVEL |
One of debug, info, warn, error, silent. Defaults to info in production, debug otherwise. |
NODE_ENV |
Standard environment flag (production in deploys). |
Tenant identity
Section titled “Tenant identity”Every tenant needs two registry entries, and they’re keyed on the same originAppId: a credential to authenticate with (ANTIPHONY_APP_TOKENS, below) and an app DID to write records under. A tenant present in only one is config drift — core-api logs a warning naming it.
| Variable | Purpose |
|---|---|
ANTIPHONY_APP_DIDS |
Comma-separated appId:did pairs pinning each tenant’s at:// authority, e.g. voxpop:did:web:did.voxpop.audio. Split on the first colon, so the DID’s own colons are safe. Required for any tenant that reads or writes posts — see the custody note below. |
ANTIPHONY_PDS_HOST |
Optional but recommended. Your Antiphony host (e.g. api.antiphony.dev). When set, a pin must also point its #atproto_pds serviceEndpoint at this host — that’s what turns “the DID document exists” into “the DID names us as its PDS”. Unset, core-api logs a warning and only requires the endpoint to be present. |
Full rationale: specs/atproto-authority-model.md.
Service-to-service auth
Section titled “Service-to-service auth”Applications (BFFs, workers) are the intended callers of the posts/audio surface. Each authenticates with its own service token and asserts the acting end user per request — the full contract lives in specs/service-auth.md.
| Variable | Purpose |
|---|---|
ANTIPHONY_APP_TOKENS |
Comma-separated appId:token pairs (tokens ≥32 chars). A caller presenting a matching Authorization: Bearer <token> is that app: its tenancy (originAppId) comes from the credential, and it asserts the acting user via X-Antiphony-Acting-Actor (+ optional X-Antiphony-Acting-Actor-Did). Store as a secret. |
SYSTEM_AUTH_TOKEN |
Shared secret for the /api/v1/system/* routes. The system-auth middleware expects Authorization: Bearer <SYSTEM_AUTH_TOKEN> and fails closed (503) if the variable is unset — these routes are service-to-service plumbing, not public API. Store it as a secret, not in plaintext config. |
Audio enrichment
Section titled “Audio enrichment”Opt-in processing runs four stages over a post’s audio: denoise and transcribe (ElevenLabs), and trim and waveform (ffmpeg, in the rendition service). None run unless the relevant variables below are set; a deployment with none of them still serves audio, it just does no enrichment. Stages requested against a deployment that can’t run them settle skipped, not pending.
Providers
Section titled “Providers”| Variable | Purpose |
|---|---|
ELEVENLABS_API_KEY |
Enables the ElevenLabs providers — Scribe (transcription) and Voice Isolator (denoise). Presence alone selects them; there is no separate enable flag. Absent → denoise/transcribe settle skipped. Store as a secret. |
ELEVENLABS_STT_MODEL |
Optional. Overrides the default Scribe model id used for transcription. Not validated against a list of known ids — a typo reaches the provider and fails the stage — so the resolved value is logged on first use. |
ANTIPHONY_RENDITION_SERVICE_URL |
Base URL of the transcode backend (apps/audio-rendition). Enables trim and waveform, and lets GET /api/v1/audio?format=mp3 build a rendition on a miss instead of 404ing. Absent is a supported state: both stages resolve unavailable and settle skipped, and the audio proxy serves only renditions that already exist. Requires SYSTEM_AUTH_TOKEN — the service is system-authed, so a URL without a token is the same failure wearing a 401. |
Choosing a provider per stage
Section titled “Choosing a provider per stage”Optional, and rarely needed: with none of these set, each stage takes the first provider it can run, which is the behavior described above.
| Variable | Purpose |
|---|---|
ANTIPHONY_TRANSCRIBER |
elevenlabs | stub |
ANTIPHONY_DENOISER |
elevenlabs | stub |
ANTIPHONY_TRIMMER |
service | stub |
ANTIPHONY_WAVEFORM |
service | stub |
Naming a stage’s provider explicitly lets you mix them — a real transcriber next to a stub denoiser while evaluating one of them, say. Two behaviors are worth knowing before you reach for these:
- A named provider that isn’t configured is a misconfiguration, not an opt-out.
ANTIPHONY_TRANSCRIBER=elevenlabswith no API key logs aterrorand leavestranscribeunavailable; it does not quietly fall back to another provider, on the same principle as the partialANTIPHONY_TASKS_*set below. stubis only ever reachable by naming it. A deployment that loses its API key reports the stage as unavailable and settles requestsskipped— it never degrades into saving stub transcripts as real records. For stubs across the board, useANTIPHONY_PROCESSING_STUB(below), which overrides all four of these.
Choosing a provider or model per tenant
Section titled “Choosing a provider or model per tenant”Optional. Pins one tenant (originAppId) to a provider or model of its own, leaving every other tenant on the deployment default. Same appId:value shape as ANTIPHONY_APP_DIDS and the webhook registries.
| Variable | Purpose |
|---|---|
ANTIPHONY_APP_TRANSCRIBERS |
voxpop:elevenlabs,acme:stub |
ANTIPHONY_APP_DENOISERS |
same shape |
ANTIPHONY_APP_TRIMMERS |
same shape |
ANTIPHONY_APP_WAVEFORMS |
same shape |
ANTIPHONY_APP_STT_MODELS |
voxpop:scribe_v2,acme:scribe_v1 — the transcription model per tenant |
Selection resolves in three layers, narrowest first: tenant pin → deployment default → first available provider. A tenant with no entry is unaffected by these existing at all.
- This is ops config, not a tenant-facing feature. A tenant cannot name its own provider or model over the API, so it can never invoke an arbitrary expensive model on your key. Changing a pin is a deploy, not a request.
- A bad pin is scoped to its tenant. An unknown or unconfigured provider name logs at
errorand leaves that stage unavailable for that tenant only — its neighbours keep working, and it does not fall back to the deployment default (which would silently overrule the pin). A malformed entry drops with a log without taking out the rest of the variable. - Capabilities are per tenant.
processingopt-ins settleskippedfor a tenant whose pinned provider can’t run, while the same request succeeds for a tenant on a working one. - A model pin aimed at a provider with no model is reported and ignored.
denoise,trim, andwaveformhave no model to set — onlyANTIPHONY_APP_STT_MODELSexists for that reason. The stage still runs, on the provider’s own default.
Dispatch
Section titled “Dispatch”Processing runs out of band, never inside the create/patch request.
On Workers this is a binding, not a variable. PROCESSING_QUEUE in wrangler.jsonc names a Cloudflare Queue whose consumer is the same Worker’s queue() handler — so there is no worker URL to configure and no token stored per task. Batch size is deliberately 1: one message is one full processing pass (a denoise call over a multi-megabyte upload, then transcription), the whole batch shares one 15-minute invocation, and blowing that budget retries every message in the batch including the ones that already succeeded. Concurrency belongs between invocations, where Cloudflare scales consumers on its own. Three attempts, then the dead-letter queue.
On a Node host the queue binding doesn’t exist, and dispatch falls to the Cloud Tasks adapter instead:
| Variable | Purpose |
|---|---|
ANTIPHONY_TASKS_LOCATION |
Cloud Tasks region, e.g. us-east4. |
ANTIPHONY_TASKS_QUEUE |
Cloud Tasks queue name. |
ANTIPHONY_TASKS_WORKER_URL |
Absolute URL of this deployment’s /api/v1/system/process-audio worker route, which the queue calls back. Must carry SYSTEM_AUTH_TOKEN (above) — the worker is system-auth’d. |
ANTIPHONY_TASKS_PROJECT |
Optional. GCP project for the queue; falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT. |
The three non-optional ANTIPHONY_TASKS_* vars are all-or-nothing: set together, or a partial set is treated as a misconfiguration (logged at error, jobs dropped) rather than a silent opt-out. The runtime service account also needs roles/cloudtasks.enqueuer.
Stage-settled webhooks
Section titled “Stage-settled webhooks”Optional. When configured, the core POSTs a small signed webhook to a tenant’s BFF each time an enrichment stage reaches a terminal state (ready / failed / skipped), so the BFF learns a result landed without polling. The webhook is a latency accelerator over the authoritative post state, not a second source of truth — a dropped delivery is a latency regression the next read reconciles, never lost data.
| Variable | Purpose |
|---|---|
ANTIPHONY_APP_WEBHOOK_URLS |
Comma-separated appId:url pairs — where to POST each tenant’s stage-settled events, e.g. voxpop:https://bff.voxpop/hooks. Split on the first colon, so a URL with a port is fine. Must be https unless the host is loopback (localhost, 127.0.0.1, ::1), which stays plaintext-friendly for developing a receiver locally. |
ANTIPHONY_APP_WEBHOOK_SECRETS |
Comma-separated appId:secret pairs, secrets ≥32 chars. The key for the X-Antiphony-Signature: sha256=<hex> header, an HMAC-SHA256 over the raw request body; the receiver recomputes and constant-time-compares. Store as a secret. |
A tenant present in both vars gets webhooks; a tenant in neither is a silent opt-out (the pull paths still work). A tenant in exactly one is a misconfiguration — logged at error and sent no webhooks, so it never pushes unsigned. An entry that fails validation — a secret under the length floor, or a plaintext http: target off loopback — is dropped with an error log for that tenant alone, on the same fail-closed principle: the signature is the receiver’s entire basis for trusting an event, so a key short enough to brute-force offline, or a hop where the payload and its signature both travel in the clear, makes it decorative. Delivery is best-effort (a short timeout and a couple of retries); a failed POST is logged and swallowed, never failing the enrichment pass. The payload carries {postId, originAppId, stage, status, occurredAt} — enough to act on without a follow-up request; the artifact itself is fetched from the post view when wanted. Receivers should treat each event as “latest wins for (postId, stage)” (a recompute legitimately re-fires ready), using occurredAt as the tiebreaker.
Development flags
Section titled “Development flags”| Variable | Purpose |
|---|---|
ANTIPHONY_PROCESSING_INLINE |
When true, runs processing synchronously inside the request — the local/test trigger, no queue needed. Wins over every durable dispatcher, so a developer with queue config in their shell can’t enqueue against a real queue by accident. |
ANTIPHONY_PROCESSING_STUB |
When true, wires pass-through stub providers instead of ElevenLabs — exercises the full create → process → hydrate loop with no key and no billing. Wins over ELEVENLABS_API_KEY, so a real key in the shell can’t accidentally bill from a test run. |
With no queue binding, no ANTIPHONY_PROCESSING_INLINE, and no ANTIPHONY_TASKS_* vars, dispatch is a no-op (logged and dropped) — enrichment is effectively off. Note the two flags govern different axes and neither implies the other: _STUB decides which providers can do the work, _INLINE decides who runs it.
Deployment
Section titled “Deployment”The hosted reference deploy at api.antiphony.dev runs on Cloudflare Workers against Neon and R2, configured by apps/core-api/wrangler.jsonc and shipped by .github/workflows/deploy.yml. See deploy/README.md for the one-time setup: the KV namespace and queues to create, the four Worker secrets, and the schema to apply.
The service layer is portable by construction — packages/core has zero backend imports and every backend touch goes through a port — so the reference deploy’s choice of runtime is not a constraint on yours. What each target has to supply is a Postgres and a blob store behind the existing ports:
- Cloudflare Workers — Neon behind
DATABASE_URL, plus an R2 bucket, a queue, a KV namespace and a Durable Object namespace. What the reference deploy runs. - A container or VM — any Postgres and any object store. You implement the
BlobStoreport against it; the Postgres bindings are already in the tree.
The one hard requirement either way is a reachable Postgres. There is no CORS allowlist to configure: core-api runs no CORS middleware, because every caller is a backend holding a service token and the one browser-facing surface — the anonymous audio proxy — is an <audio src=…> no-cors load governed by Cross-Origin-Resource-Policy.
Where next?
Section titled “Where next?”- Quick start — get it running locally first.
- Build your own app — point a client at your deployment.
- API reference — the surface your deployment exposes.