---
title: "Deployment"
description: "The first Cloudflare environment: Workers, D1, KV, R2, queues and secrets."
source: "docs/deployment.md"
---

> Part of [Overview](/overview/llms.txt). Every page: [llms.txt](/llms.txt).

# Deployment (first environment, 2026-10-17)

The production domain is **clarisay.com** (Cloudflare Registrar, zone in the same account). All three Workers serve it
through Workers Custom Domains (`routes` with `custom_domain: true` in each `wrangler.jsonc`;
`wrangler deploy` creates the DNS records and certificates). Their `workers.dev` URLs stay enabled: TestFlight builds up
to 21 call the backend's `workers.dev` URL. Everything below was created with `wrangler` from the respective track
directory; ids live in each `wrangler.jsonc`. The dashboard's `mock` and `integration` environments set `routes: []` so
they never claim the production hostname (wrangler environments inherit `routes`).

| Project | URL | Also reachable at | Worker |
|---|---|---|---|
| Backend API | https://api.clarisay.com (`/health`, `/v1/...`) | https://shuke-api.dou-tech.workers.dev | `shuke-api` |
| Operator dashboard | https://dash.clarisay.com | https://shuke-dashboard.dou-tech.workers.dev | `shuke-dashboard` |
| Landing | https://clarisay.com (`/`, `/zh`), https://www.clarisay.com | https://shuke-landing.dou-tech.workers.dev | `shuke-landing` |

`ios/Config/Release.xcconfig` (`API_BASE_URL`) points at `https://api.clarisay.com/v1` for the next
TestFlight build.
`dashboard/wrangler.jsonc` `BACKEND_URL` names `https://api.clarisay.com/v1`; the request still travels over the service
binding.

## Resources

| Resource | Name / id |
|---|---|
| D1 | `shuke` — `5208d8af-6b45-4abe-bf6d-4b40743f725a` (migrations 0001–0002 applied, `seeds/` loaded) |
| KV | `CONFIG` `aec399b3372f4f9b81fb8cd5ece9c9a5`, `IDEMPOTENCY` `0d739a118cca450790c0b93f55d4ee8e`, landing `WAITLIST` `c4ad99d059f544ac8a410b874892bcd4` |
| R2 | `shuke-audio` |
| Queues | `shuke-pipeline` (producer + consumer), `shuke-pipeline-dlq` |
| Vectorize | `shuke-content` (1024 dims, cosine, metadata index `language`) — bound as `CONTENT_INDEX` |
| Crons | `0 18 * * *`, `30 18 * * *`, `5 * * * *`, `0 19 * * *` |

## Secrets

Backend: `JWT_SECRET`, `ADMIN_KEY` (set with `wrangler secret put`). The generated values are in
`~/.shuke/production-secrets.env` on the machine that deployed (mode 600, not in git). Rotate with
`openssl rand -hex 32 | pnpm wrangler secret put JWT_SECRET` (existing access tokens expire within an hour).

Dashboard: **no secrets**. Cloudflare Access (below) authenticates operators; the proxy forwards the Access assertion.

## AI providers (production, since 2026-10-17)

`ENVIRONMENT=production`; secrets `ANTHROPIC_API_KEY` (a key created inside a **named** Anthropic workspace — keys from
the Default workspace are rejected with "not scoped to a workspace") and `OPENAI_API_KEY`. Active chains: ASR
Workers AI whisper-large-v3-turbo → OpenAI whisper-1; LLM/content/QA Anthropic claude-sonnet-5-5 → OpenAI gpt-4.1;
TTS OpenAI gpt-4o-mini-tts → Workers AI MeloTTS; embeddings Workers AI bge-m3. The saved config lives in KV
`ai:providers` (Dashboard → Providers); the mock providers are refused under `production`.

Sign in with Apple (done 2026-10-17): secrets `APPLE_TEAM_ID` (Z9KD6Z92BF), `APPLE_KEY_ID` (XZ9YBHF9AH, key "Shuke
Sign in with Apple", primary App ID com.weizhi.shuke) and `APPLE_PRIVATE_KEY` (the one-time `.p8`; keep the backup
outside Downloads). Verified: a client secret signed with the key gets `invalid_grant` (not `invalid_client`) from
`https://appleid.apple.com/auth/token`, i.e. Apple accepts the key for this client id.

## RevenueCat (purchases)

Set up 2026-10-17 in the RevenueCat dashboard: project **Clarisay** → app "Clarisay (App Store)" (`com.weizhi.shuke`,
In-App Purchase key `T5AP5D989H`, App Store Connect API key `D9FG72BMZ7`), products `clarisay.pro.annual` /
`clarisay.pro.monthly`, entitlement `pro`, offering `default` (`$rc_annual`, `$rc_monthly`). The app's public SDK key is in
`ios/Config/Shared.xcconfig` (`REVENUECAT_API_KEY`). The first product records (`shuke.pro.*`, created before the
Clarisay rename) are detached from `pro` and out of the offering; they never existed in App Store Connect and can be
deleted. Operator steps still open:

1. App Store Connect: create the auto-renewable subscriptions `clarisay.pro.annual` (7-day free trial intro offer) and
   `clarisay.pro.monthly` in one subscription group. Prices (doubled from the first plan on 2026-10-17): annual
   **$79.99 / ¥396**, monthly **$13.99 / ¥60** (US base price; use the nearest available price point where a storefront
   has no exact match). RevenueCat shows them as "Not found" until then.
2. RevenueCat → project **Clarisay** → API keys → **New secret API key** (v1), then
   `cd backend && pnpm wrangler secret put REVENUECAT_API_KEY`. Secret keys are scoped to one RevenueCat project: a key
   from another project (JuiceUp, OS, …) cannot read Clarisay's customers.
3. Generate a webhook secret (`openssl rand -hex 32`), store it with
   `pnpm wrangler secret put REVENUECAT_WEBHOOK_AUTH` (the exact header value, e.g. `Bearer <hex>`), and in RevenueCat →
   Integrations → Webhooks add `https://api.clarisay.com/v1/webhooks/revenuecat` with that Authorization
   header value. Send the dashboard's test event; it should return 200.
4. Optional: RevenueCat app settings → Apple Server Notification URL → "Apply in App Store Connect", and set the
   forwarding URL to `https://api.clarisay.com/v1/webhooks/apple` so pre-RevenueCat builds stay current.

Without steps 2–3, `/v1/subscriptions/revenuecat/sync` answers `503 subscription_unavailable` and the webhook `401`.

## Cloudflare Access for the dashboard (done 2026-10-17)

The dashboard calls the backend through a **service binding** (`services: BACKEND → shuke-api` in
`dashboard/wrangler.jsonc`): two Workers on one `workers.dev` subdomain cannot `fetch` each other (error 1042).

Zero Trust team `dou-tech` (`dou-tech.cloudflareaccess.com`), Access → Applications → **shuke-dashboard**
(self-hosted, destination = the `shuke-dashboard` Worker's production and preview URLs, application id
`5e9e3eca-b81b-4c52-b406-07495cbb4f51`), policy **Allow owner** (reusable, shared with the other dashboards),
session 24 h. `ACCESS_TEAM_DOMAIN` is set on both Workers and `ACCESS_AUD` on the backend (`wrangler.jsonc` vars).

- Unauthenticated `GET /` on the dashboard → 302 to the Access login; the proxy forwards `Cf-Access-Jwt-Assertion`
  and never injects an admin key.
- The backend verifies the assertion against the team's public keys and, under `ENVIRONMENT=production`, refuses
  `X-Admin-Key` (401). Scripts that used the admin key against production (e.g. `smoke.sh`'s admin section) no
  longer work there; run them against a local `wrangler dev` or set up an Access **service token** and send
  `CF-Access-Client-Id/Secret`.
- To add an operator: Zero Trust → Access controls → Policies → *Allow owner* → add the email (or create a second
  policy and attach it to the application).
- `dash.clarisay.com` is a public hostname destination of the same *shuke-dashboard* application (one application,
  so the AUD tag `ACCESS_AUD` on the backend is unchanged). Unauthenticated requests get a 302 to the Access login.
- Once operators use `dash.clarisay.com`, set `"workers_dev": false` in `dashboard/wrangler.jsonc`, deploy, and remove
  the Worker URL destinations from the Access application.

## Verification done on 2026-10-17

clarisay.com, after deploying `c8dcb19` (backend `3353c750`, landing `93476b3e`, dashboard `ab739534`):

- `https://api.clarisay.com/health` and `/v1/config` 200; `/v1/paywall` without a token 401; CORS preflight from
  `Origin: https://clarisay.com` returns `Access-Control-Allow-Origin: https://clarisay.com`.
- `https://clarisay.com` `/`, `/zh`, `/terms`, `/privacy`, `/sitemap.xml`, `/robots.txt`, `/api/health` 200; canonical
  and sitemap URLs use `https://clarisay.com`. `https://www.clarisay.com/` 200.
- `https://dash.clarisay.com/` and `/api/proxy/admin/overview` → 302 to the `dou-tech` Access login.
- The `workers.dev` URLs still answer (backend `/health` 200, dashboard 302 to Access).

First environment (workers.dev):

- `BASE=https://shuke-api.dou-tech.workers.dev SMOKE_ALLOW_NO_FETCH=1 pnpm smoke` → `smoke OK` (anonymous auth,
  onboarding, upload, queue pipeline to `assessed`, settlement replay, paywall 402, brief → review → publish → public
  catalog). The source adaptation is skipped: `CONTENT_FETCH_ENABLED=false` on the Worker (ADR-002) and the fixture
  server is local. The smoke article was unpublished and its sources archived afterwards.
- Dashboard `/api/health` 200; `/api/proxy/admin/overview|review|sources|quality` 200 with real data through the
  service binding (checked with a temporary `ADMIN_KEY`, since removed).
- Landing `/`, `/zh`, `/privacy`, `/zh/support`, `/sitemap.xml`, `/api/health` 200; `POST /api/waitlist` 201 (the test
  entry was deleted from KV).

## Known gaps

- `CONTENT_FETCH_ENABLED` stays `false` until the starter sources are reviewed (ADR-002).
- `PUBLIC_BASE_URL` is empty on purpose: media URLs use the request origin, so old builds on `workers.dev` and new
  builds on `api.clarisay.com` each get URLs on their own host.
- `www.clarisay.com` serves the same pages as the apex (canonical tags point at the apex); a Cloudflare Redirect Rule
  www → apex would make it a 301.
- Worker, D1, R2, KV and queue names still say `shuke`; renaming them is part of the brand rename, not the domain.
- The backend's optional `WAITLIST` KV binding (waitlist export) is not configured.
