You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs: correct env/deploy model and sync admin write-protection in CLAUDE.md
Runtime server secrets (incl. ADMIN_SECRET) are read from the Cloudflare
Worker's own Variables and Secrets at runtime — that's the authoritative
production source; NEXT_PUBLIC_* are inlined at build time (local .env.local
or CI secrets). Document both deploy paths (manual wrangler + GitHub Actions
on push to main) instead of implying GitHub secrets hold runtime values. Also
sync the write-protection prose to the merged UI: adminFetch opens a styled
unlock dialog that verifies via POST /api/admin/verify, plus a nav lock with
login/logout.
https://claude.ai/code/session_01UcazDxsJCyZoTop3psfTY2
`SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_MAPS_API_KEY`, and `ADMIN_SECRET` must never reach the client — the Places key is used only server-side in `lib/places.ts`, and the browser hits `/api/places/{autocomplete,details}` instead. Local dev reads from `.env.local`; production deploys via `.github/workflows/deploy.yml` inject the build environment from GitHub Actions secrets (`NEXT_PUBLIC_*` get inlined into the bundle by Next.js at build time, so they don't need a `[vars]` block in `wrangler.toml`). **Add `GOOGLE_MAPS_API_KEY` as a GitHub Actions secret and pass it through in `deploy.yml`**, otherwise the deployed Worker's Places routes return 503. **`ADMIN_SECRET` is wired the same way — set it in `.env.local` for dev and as a GitHub Actions secret (already passed through in `deploy.yml`); if it's missing, every guarded route fails closed with 503, so the curator can't edit either.** `.env.local.example` also lists `TELEGRAM_BOT_TOKEN` / `TELEGRAM_WEBHOOK_URL` but no Telegram routes currently exist in the codebase — treat those as dormant.
50
+
`SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_MAPS_API_KEY`, and `ADMIN_SECRET` must never reach the client — the Places key is used only server-side in `lib/places.ts`, and the browser hits `/api/places/{autocomplete,details}` instead.
51
+
52
+
**Where each variable lives.** Local dev reads everything from `.env.local`. In production the two kinds of variable live in different places:
53
+
54
+
-**`NEXT_PUBLIC_*`** are inlined into the JS bundle by Next.js **at build time**, so they must be present wherever `cf:build` runs — in `.env.local` for a local build, and as GitHub Actions secrets for the CI workflow. They are not runtime Worker vars and don't need a `[vars]` block in `wrangler.toml`.
55
+
-**Server-only secrets** (`SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_MAPS_API_KEY`, `ADMIN_SECRET`) are read via `process.env`**at runtime** by the open-next adapter, sourced from the **Cloudflare Worker's own Variables and Secrets** (dashboard → Workers → `food-picker` → Settings → Variables and Secrets, as encrypted **Secrets**, or `wrangler secret put`). That Worker config is the authoritative production source — set them there. If `GOOGLE_MAPS_API_KEY` is missing the Places routes return 503; if `ADMIN_SECRET` is missing every guarded route fails closed with 503, so the curator can't edit either.
56
+
57
+
Two things can deploy the Worker, both running `wrangler deploy`: a **manual** deploy (`npm run cf:deploy`, or `bash scripts/deploy.sh`) and the **GitHub Actions** workflow `.github/workflows/deploy.yml`, which fires on every push to `main`. `.env.local.example` also lists `TELEGRAM_BOT_TOKEN` / `TELEGRAM_WEBHOOK_URL` but no Telegram routes currently exist in the codebase — treat those as dormant.
51
58
52
59
### Write protection (`ADMIN_SECRET`)
53
60
54
-
The DB is single-curator: only the creator writes, friends only read and ask for recommendations. All API routes use the service-role client, which bypasses Supabase RLS, so the gate lives in the route handlers, not the database. `requireAdmin()` (`lib/admin-auth.ts`) compares an `x-admin-secret` header against `ADMIN_SECRET` and is called at the top of every **mutating or cost-incurring** route: `POST /api/restaurants`, `PATCH`/`DELETE /api/restaurants/[id]`, `generate-tags`, `generate-summary`, and `places/{autocomplete,details}`. Reads (`GET`) and the friend-facing `/api/recommend` + `/api/recommend/feedback` stay open. On the client, `adminFetch()` (`lib/admin-client.ts`) attaches the header from `localStorage`, prompting the curator once on a 401 (passive callers like address autocomplete pass `prompt: false` so typing is never interrupted). This is a shared password, not per-user auth — it stops anonymous scripts from wiping or spamming the DB; it does not authenticate individuals.
61
+
The DB is single-curator: only the creator writes, friends only read and ask for recommendations. All API routes use the service-role client, which bypasses Supabase RLS, so the gate lives in the route handlers, not the database. `requireAdmin()` (`lib/admin-auth.ts`) compares an `x-admin-secret` header against `ADMIN_SECRET` and is called at the top of every **mutating or cost-incurring** route: `POST /api/restaurants`, `PATCH`/`DELETE /api/restaurants/[id]`, `generate-tags`, `generate-summary`, and `places/{autocomplete,details}`. Reads (`GET`) and the friend-facing `/api/recommend` + `/api/recommend/feedback` stay open. On the client, `adminFetch()` (`lib/admin-client.ts`) attaches the header from `localStorage`; when the secret is missing or rejected it opens a styled unlock dialog (`components/AdminUnlockDialog.tsx`) that verifies the input against `POST /api/admin/verify` before storing it, so a wrong password shows an inline error instead of silently failing (passive callers like address autocomplete pass `prompt: false` so typing is never interrupted). A lock control in the nav (`components/AdminNavLock.tsx`) reflects locked/unlocked state via `useSyncExternalStore` and offers proactive login/logout — logout clears the stored secret on that device. This is a shared password, not per-user auth — it stops anonymous scripts from wiping or spamming the DB; it does not authenticate individuals.
55
62
56
-
### Cloudflare deploy prerequisites
63
+
### Cloudflare deploy auth
57
64
58
-
The deploy workflow needs two more GitHub Actions secrets:
65
+
Both deploy paths run `wrangler deploy`, so wrangler needs Cloudflare auth — `wrangler login` (browser OAuth) for a local deploy, or these two for the GitHub Actions workflow (read from GitHub Actions secrets):
59
66
60
-
|Secret| Notes |
67
+
|Variable| Notes |
61
68
|---|---|
62
69
|`CLOUDFLARE_API_TOKEN`| Create via the dashboard preset **"Edit Cloudflare Workers"** — not a hand-rolled token with only `Workers Scripts: Edit`. The asset-upload-session endpoint requires the full preset's scopes (Workers Scripts/Routes/KV/R2, Account Settings, User Details, Memberships). |
63
70
|`CLOUDFLARE_ACCOUNT_ID`| The account that owns the `food-picker` Worker. |
64
71
65
-
If deploy fails with `entitlements.not_available [code: 10007]` on the `assets-upload-session` call, the cause is one of: token missing scopes, account hasn't accepted the current Workers TOS in the dashboard, or wrong `CLOUDFLARE_ACCOUNT_ID`. The "Cloudflare auth diagnostics" step in `deploy.yml` runs `wrangler whoami`before deploy — check its output (token permissions and account list) in the failed Actions run first.
72
+
These authenticate the deploy only — the app's **runtime** secrets are not set here; they live on the Worker itself (see Environment variables above). If deploy fails with `entitlements.not_available [code: 10007]` on the `assets-upload-session` call, the cause is one of: token missing scopes, account hasn't accepted the current Workers TOS in the dashboard, or wrong `CLOUDFLARE_ACCOUNT_ID`. Run `wrangler whoami`to check token permissions and the account list first.
66
73
67
74
## Architecture — things that need >1 file to understand
0 commit comments