Deploy to Cloudflare Workers
A common pattern is a public marketing page with an embedded AI chat — no login, one free message per visitor, talking to a Divinci Release. The catch: the page is anonymous, but you still need to authenticate to the API and keep your credentials off the client.
The clean way to do this on Cloudflare is a static frontend + a small Worker proxy. The browser never holds a credential; the Worker holds the secret, enforces quota, and signs each upstream call.
Architecture
Section titled “Architecture”Browser (React island) │ POST /api/chat-send { email, newPrompt, transcript, ... } ▼Cloudflare Worker (src/worker.ts) │ • Basic-Auth gate (preview only) │ • per-email quota (KV + Durable Object) │ • HMAC-signs the call │ POST {DIVINCI_API_BASE}/ai-chat/anonymous-chat │ X-Landing-Page-Ts, X-Landing-Page-Sig ▼Divinci API → verifies the signature, runs RAG + the Release's model chainThe Worker runs first for every request (run_worker_first = true) and
serves static assets via the ASSETS binding for everything that isn’t /api/*.
Authentication: the landing-page HMAC (not an API key)
Section titled “Authentication: the landing-page HMAC (not an API key)”When a Release has requireSignedAnonymousChat enabled, the API only
accepts anonymous-chat calls that carry a valid signature. The Worker stamps two
headers on each upstream request:
X-Landing-Page-Ts: <unix-epoch-seconds>X-Landing-Page-Sig: HMAC-SHA256(LANDING_PAGE_HMAC_KEY, `${ts}.${releaseId}.${newPrompt}`)The API recomputes the HMAC with the same shared secret and rejects the request
(403 landing_page_sig_invalid) when the signature doesn’t match, the headers
are missing, or abs(now - ts) > 300 (a 5-minute replay window). Including
newPrompt in the signed payload means a snooped (ts, sig) pair can’t be
reused to send a different prompt.
// Worker side — sign the upstream callasync function signHeaders(key: string, releaseId: string, newPrompt: string) { const ts = Math.floor(Date.now() / 1000); const mac = await crypto.subtle.importKey( "raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sigBuf = await crypto.subtle.sign( "HMAC", mac, new TextEncoder().encode(`${ts}.${releaseId}.${newPrompt}`), ); const sig = [...new Uint8Array(sigBuf)].map((b) => b.toString(16).padStart(2, "0")).join(""); return { "X-Landing-Page-Ts": String(ts), "X-Landing-Page-Sig": sig };}The same secret is set on both sides — the Worker (wrangler secret put LANDING_PAGE_HMAC_KEY) and the Divinci API for that environment. They must be
byte-for-byte identical or every call 403s.
Release configuration
Section titled “Release configuration”The chat runs against a Divinci Release, and three Release fields gate anonymous access. They layer — “is the door open?” then “do you need the key?”:
| Field | Set to | Effect |
|---|---|---|
allowAnonymousChat | true | Master switch. While false, the API returns 403 FORBIDDEN — no anonymous chat at all. |
requireSignedAnonymousChat | true | Require the HMAC signature, so only your Worker can call the endpoint. |
maxAnonymousChatMessages | e.g. 12 | Per-conversation message cap (default 1 — raise for multi-turn). |
Configuration
Section titled “Configuration”Non-secret config goes in wrangler.toml [vars]; secrets go in Wrangler
secrets (never the repo):
name = "my-landing"main = "./src/worker.ts"compatibility_flags = ["nodejs_compat"]
[assets]directory = "./dist"binding = "ASSETS"run_worker_first = true
[vars]DIVINCI_API_BASE = "https://api.divinci.app"DIVINCI_RELEASE_ID = "<your release id>"# Secrets — per environment, not committedwrangler secret put LANDING_PAGE_HMAC_KEY # must match the API's valuewrangler secret put BASIC_AUTH_PASSWORD --env staging # optional preview gateLocal development
Section titled “Local development”The Worker owns every /api/* route, so you must run the Worker, not just a
static dev server. A plain static/SPA dev server (e.g. astro dev, vite)
serves your pages but never runs src/worker.ts — so /api/* returns
404. Use wrangler dev.
-
Local secrets —
.dev.vars(gitignored).wrangler devreads it for the Worker’s secrets:Terminal window # .dev.vars — do NOT commitLANDING_PAGE_HMAC_KEY=<same value as the target env's API + worker secret> -
Run the Worker:
Terminal window wrangler dev # → http://localhost:8787, runs the real Worker + /apiFor frontend hot-reload too, run your static dev server alongside and proxy
/apito the Worker (e.g. a viteserver.proxyof/api → localhost:8787). Pin the Worker’s port ([dev] port = 8787inwrangler.toml) so the proxy target doesn’t drift.
Common gotchas
Section titled “Common gotchas”| Symptom | Cause / fix |
|---|---|
404 on /api/* | Static dev server is running but the Worker isn’t. Run wrangler dev. |
502 + Origin ... not allowed by CORS | The Worker is forwarding an Origin header. Don’t — it’s a server-to-server call; omit Origin entirely and the API allows it (HMAC is the gate). No allowlisting needed. |
403 landing_page_sig_invalid | The LANDING_PAGE_HMAC_KEY in .dev.vars doesn’t match the API’s. Use the exact env value — and make sure it’s the HMAC key, not an API key. |
402 quota_exhausted | The visitor used their free message. It’s local Durable-Object/KV state under wrangler dev — clear it with rm -rf .wrangler/state, or wire an admin reset endpoint. |
Conversation persistence (optional)
Section titled “Conversation persistence (optional)”Anonymous chat is stateless by default — the signed transcript lives in the
visitor’s browser. To get usage analytics and feedback-conversation links,
send a stable per-visitor sessionId with each anonymous-chat (and
feedback) call; the API then persists the conversation as a customer chat,
visible in the workspace’s Customer Chats view.
// Browser: mint once, keep in localStorageconst sessionId = crypto.randomUUID();
// Worker: include it in the upstream body alongside the signed fieldsbody: JSON.stringify({ releaseId, prevSigniture, newPrompt, transcript, sessionId })Rules worth knowing:
- Format: 8–128 chars of
[A-Za-z0-9_-]. Anything else is rejected server-side and the conversation simply isn’t persisted (chat still works). - Scope: one persisted chat per
(sessionId, release). - Append-only: the server reconciles by exchange timestamps and never shrinks a stored transcript — a page reload that restarts the client’s transcript chain appends rather than overwrites.
- Privacy: persist the conversation, not the identity — the gate email is not stored on the chat.
Deploy
Section titled “Deploy”# build the static assets, then deploy the Worker + assets atomicallynpm run buildwrangler deploy --env stagingwrangler deploy # productionStatic assets and the Worker deploy together — there’s no separate asset upload step and no partial-deploy window.
Related
Section titled “Related”- Authentication — API keys, user tokens, and SDK-level anonymous chat (when you do want a credential, not an HMAC).
- Releases — configuring the Release your landing page chats against, including anonymous-chat and signed-request settings.
Divinci-AI/drfuhrman-ai-landing— the full reference implementation.