The /admin/* route is protected by two independent layers:
Cloudflare Access intercepts all requests to rvajames.org/admin before they
reach the Worker. Unauthenticated requests are redirected to the Cloudflare
login page. Authenticated sessions are validated by Cloudflare's JWT middleware,
which sets the cf-access-authenticated-user-email header.
Configuration: Zero Trust → Access → Applications → Self-hosted → rvajames.org/admin
lib/admin/auth.ts reads cf-access-authenticated-user-email and verifies it
is in the ALLOWED_ADMIN_EMAILS env var (comma-separated, case-insensitive).
If the header is absent or the email is not in the allowlist, requireAdminEmail()
throws Response('Forbidden', { status: 403 }).
This second check prevents accidental access if:
- The Cloudflare Access policy is misconfigured
- A request somehow bypasses the edge (e.g. direct Worker URL)
/admin/closures allows authenticated admins to:
- Create, edit, and expire access-point closures
- Approve or discard draft closures created by automated scrapers
- Duplicate existing closures (e.g. recurring annual closures)
All admin writes use the Supabase service role key (bypasses RLS). Admin reads are also service-role to ensure draft rows are visible.
This secret must be set in Cloudflare Workers secrets:
echo "admin@example.com" | wrangler secret put ALLOWED_ADMIN_EMAILSRotating access: update the env var and redeploy. No code change needed.
Row Level Security is enabled on all seven Supabase tables:
locations, activities, location_activities, conditions_snapshots,
advisories, ai_interpretations, ingestion_runs.
Each table has a single anon_read policy that allows anonymous clients to
SELECT but not INSERT, UPDATE, or DELETE. Cron routes authenticate
with the service role key, which bypasses RLS — those routes are the only
path that writes data.
Migration: supabase/migrations/0002_rls.sql
| Name | Used by | Rotation |
|---|---|---|
SUPABASE_URL |
All server-side Supabase calls | Permanent per project |
SUPABASE_ANON_KEY |
Public reads (anon PostgREST) | Rotate if leaked |
SUPABASE_SERVICE_ROLE_KEY |
Cron write routes | Rotate immediately if leaked |
NEXT_PUBLIC_SUPABASE_URL |
Client-side Supabase URL (build-time) | Same as SUPABASE_URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Client-side anon key (build-time) | Same as SUPABASE_ANON_KEY |
ANTHROPIC_API_KEY |
AI interpretation cron | Rotate immediately if leaked |
CRON_SECRET |
Guards all /api/cron/* endpoints |
Rotate if leaked; update Worker secret + Wrangler trigger config |
ALLOWED_ADMIN_EMAILS |
Admin route email allowlist | Update to add/remove admin access |
In production, secrets are stored in Cloudflare Workers secrets via wrangler secret put.
NEXT_PUBLIC_* vars are set as plain vars in wrangler.jsonc (build-time accessible, not secret).
Never commit .env.development.local, .dev.vars, or .env.read-prod — all
three are gitignored. The legacy filename .env.local is also gitignored to
catch accidental recreation; the canonical name is now .env.development.local
(which Next.js auto-loads when NODE_ENV=development).
| File | Holds | Read by |
|---|---|---|
.env.development.local |
Local-dev credentials (local Supabase, dev Anthropic key, dev CRON_SECRET) | next dev, wrangler dev (via .dev.vars symlink), tsx scripts |
.dev.vars |
Symlink to .env.development.local |
wrangler dev (filename is Wrangler-mandated) |
.env.read-prod |
Only AGENT_READ_DATABASE_URL — SELECT-only Postgres role |
pnpm sync:prod-to-local, pnpm query:prod |
| (none on disk) | Production write credentials | wrangler secret put only — encrypted at the edge |
.env.read-prod is the only file on disk containing any production credential.
The role it holds is enforced SELECT-only by Postgres, so even if the file
leaked the blast radius is bounded to data read access.
Every /api/cron/* route checks for this value in either the x-cron-secret
header or the Authorization: Bearer <secret> header before processing.
Cloudflare Cron Triggers call the route over the public internet on the same
Workers domain, so this secret prevents unauthorized triggering of data-write
operations.
Implementation: lib/ingest/run.ts — guardCronSecret(request)
The interpret cron runs once per day (15 6 * * *) and generates at most 45
interpretations (9 locations × 5 age buckets). Deduplication by SHA-256
prompt hash prevents re-generating interpretations that already exist for the
same date + location + age bucket + prompt content.
Approximate daily ceiling at list prices (2025):
- Haiku 4.5 (standard): ~$0.02–0.05 / day
- Sonnet 4.6 (high-severity escalation): adds ~$0.05–0.20 on advisory days
The /status page displays today's estimated cost and warns if it exceeds $1.
Open an issue at the project repository or contact mike.garrett@teamcolab.com.