diff --git a/CLAUDE.md b/CLAUDE.md
index ef6a2e7e..4a7c6831 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,7 +134,7 @@ When both `DATASMITH_CF_ACCESS_*` vars are set, `get_client()` and `get_async_cl
### Public read-only access (RLS)
-Four tables have Row Level Security enabled with `public_read` SELECT-only policies for the `anon` role: `repositories`, `pull_requests`, `candidate_containers`, `harbor_runs`. The service-role key bypasses RLS, so pipeline processes are unaffected. See `supabase/migrations/00012_public_read_rls.sql`.
+Four tables are readable by the `anon` role: `repositories`, `pull_requests`, `candidate_containers`, `harbor_runs`. Migration `00012_public_read_rls.sql` enables RLS with a `public_read` SELECT policy on those tables; migration `00015_revoke_anon_select.sql` revokes Supabase's default broad `anon` `SELECT` grant and re-grants it only on the four, so every other table returns `permission denied`. The service-role key bypasses both layers, so pipeline processes are unaffected. Public anon access is served on `https://api.formulacode.org` (no Cloudflare Access gate); pipeline operators continue to use `https://db.formulacode.org` with CF Access + service-role key.
### Key tables
diff --git a/docs/guide/remote-access.md b/docs/guide/remote-access.md
index 891b3437..bed266a0 100644
--- a/docs/guide/remote-access.md
+++ b/docs/guide/remote-access.md
@@ -1,37 +1,41 @@
# Remote Access
-The fc-data Supabase instance is not exposed on the public internet. Two paths
-reach it from outside the host:
+The fc-data Supabase instance is not exposed on the public internet. Two
+hostnames front the same tunnel:
-1. **Full read/write access** over a Cloudflare Tunnel, gated by Cloudflare
- Access service tokens. Intended for pipeline operators running `fc-data`
- against the shared database.
-2. **Public read-only access** via the Supabase anon key. Intended for
- client-side websites that display dataset statistics.
+| Hostname | CF Access gate | Credential | Scope |
+|---|---|---|---|
+| `db.formulacode.org` | yes, service-token policy | service-role key | full read/write |
+| `api.formulacode.org` | no | anon (publishable) key | `SELECT` on four tables |
+
+Pipeline operators use `db.*`; public websites use `api.*`. Two independent
+credentials (service token + service-role key) must both leak before full
+write access is at risk.
## Architecture
```mermaid
flowchart LR
- User["Remote fc-data
(service token)"]
+ User["Pipeline operator
(service-role key + CF token)"]
Web["Public website
(anon key)"]
- CFE["Cloudflare Edge
db.formulacode.org"]
- CFA["Cloudflare Access"]
+ CFE1["db.formulacode.org"]
+ CFE2["api.formulacode.org"]
+ CFA["Cloudflare Access
(service-token policy)"]
subgraph Host["Host machine"]
- CFD["cloudflared"]
+ CFD["cloudflared
(datasmith-db tunnel)"]
SB["Supabase :54321
PostgREST + RLS"]
end
- User -->|CF-Access headers + service-role key| CFE
- Web -->|anon key| CFE
- CFE --> CFA
- CFA --> CFD
+ User --> CFE1 --> CFA --> CFD
+ Web --> CFE2 --> CFD
CFD --> SB
```
-Cloudflare Access blocks every request at the edge unless it carries a valid
-service token. The anon-key path works because the RLS policies in
-`supabase/migrations/00012_public_read_rls.sql` allow `SELECT` for the `anon`
-role on four tables; writes and all other tables are rejected by RLS.
+On `db.*`, Cloudflare Access rejects any request without a valid service
+token before it reaches Supabase. On `api.*`, there is no Access application;
+Supabase's `anon` role has `SELECT` grants on four tables only (see
+`supabase/migrations/00012_public_read_rls.sql` and
+`supabase/migrations/00015_revoke_anon_select.sql`), so anything else returns
+`permission denied`.
---
@@ -62,7 +66,9 @@ fc-data --preflight
### Public read-only access
Use the Supabase anon key (shown as the "Publishable" key in
-`supabase status`). No tunnel credentials are required; the host is the same.
+`supabase status`) against `https://api.formulacode.org`. No Cloudflare
+Access credentials are required for this hostname; the anon key is the only
+auth layer.
| Table | Exposed |
|-------|---------|
@@ -74,7 +80,7 @@ Use the Supabase anon key (shown as the "Publishable" key in
Example:
```js
-const SUPABASE_URL = "https://db.formulacode.org";
+const SUPABASE_URL = "https://api.formulacode.org";
const ANON_KEY = "sb_publishable_...";
const res = await fetch(
@@ -88,7 +94,9 @@ const res = await fetch(
);
```
-Writes return `HTTP 403: new row violates row-level security policy`.
+Writes are rejected by RLS (`HTTP 401`, `new row violates row-level security
+policy`). Reads of any table outside the four listed above return
+`permission denied for table `.
---
@@ -127,15 +135,27 @@ credentials-file: /home//.cloudflared/.json
ingress:
- hostname: db.formulacode.org
service: http://localhost:54321
+ - hostname: api.formulacode.org
+ service: http://localhost:54321
- service: http_status:404
```
-### 4. DNS record
+Both hostnames proxy to the same Supabase port. `db.*` is the gated
+read/write path; `api.*` is the ungated public-read path. The distinction
+lives in Cloudflare Access (step 6), not in the tunnel config.
+
+### 4. DNS records
```bash
cloudflared tunnel route dns datasmith-db db.formulacode.org
+cloudflared --config ~/.cloudflared/config-db.yml tunnel route dns \
+ --overwrite-dns datasmith-db api.formulacode.org
```
+The `--config` + `--overwrite-dns` flags on the second command are necessary
+because `cloudflared` resolves the tunnel name against the default config;
+without them it will route the CNAME to the wrong tunnel.
+
### 5. Run the tunnel
```bash
@@ -147,7 +167,7 @@ sudo systemctl enable --now cloudflared
At this point `https://db.formulacode.org` proxies to local Supabase but
Cloudflare Access blocks everything until the policy is in place.
-### 6. Cloudflare Access policy
+### 6. Cloudflare Access policy — for `db.*` only
In [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → Access →
Applications, add a self-hosted app:
@@ -162,16 +182,29 @@ Then under Access → Service Auth → Service Tokens, create a token (e.g.
is only shown once. Hand these to the requesting user along with the
service-role key.
-### 7. Apply the RLS migration
+**Do not create an Access application for `api.formulacode.org`.** That
+hostname must remain ungated so anon-key clients can reach PostgREST. The
+anon key plus RLS grants are the only auth layer there.
+
+### 7. Apply the Supabase migrations
-The anon-key path requires `supabase/migrations/00012_public_read_rls.sql` to
-be applied:
+The anon-key path depends on two migrations being applied against the host
+Supabase instance:
```bash
-docker exec supabase_db_ psql -U postgres -d postgres \
- -c "$(cat supabase/migrations/00012_public_read_rls.sql)"
+docker exec -i supabase_db_ psql -U postgres -d postgres \
+ < supabase/migrations/00012_public_read_rls.sql
+docker exec -i supabase_db_ psql -U postgres -d postgres \
+ < supabase/migrations/00015_revoke_anon_select.sql
```
+`00012` enables RLS and adds the `public_read` SELECT policy on the four
+exposed tables. `00015` revokes the default `anon` `SELECT ON ALL TABLES`
+grant that Supabase ships with, then re-grants it only on those four tables
+and sets the default for future tables to anon-invisible. Without `00015`,
+anon can read every table because Supabase's default grants override the
+absence of RLS.
+
### How the client picks up the headers
When both `DATASMITH_CF_ACCESS_CLIENT_ID` and
diff --git a/supabase/migrations/00015_revoke_anon_select.sql b/supabase/migrations/00015_revoke_anon_select.sql
new file mode 100644
index 00000000..85a6b677
--- /dev/null
+++ b/supabase/migrations/00015_revoke_anon_select.sql
@@ -0,0 +1,17 @@
+-- Supabase's default grants give the anon role SELECT on every table in the
+-- public schema. Migration 00012 only enabled RLS (and added a public_read
+-- policy) on four tables, leaving the rest unprotected: with RLS disabled,
+-- the default grant wins and anon can read them via PostgREST.
+--
+-- Lock this down: revoke the broad anon SELECT, re-grant only the four
+-- intentionally-public tables, and change the default privilege so any
+-- table added later is anon-invisible unless explicitly granted.
+--
+-- The service-role key bypasses grants (superuser-equivalent), so the
+-- pipeline is unaffected. The grafana_ro role is a separate principal.
+
+REVOKE SELECT ON ALL TABLES IN SCHEMA public FROM anon;
+
+GRANT SELECT ON repositories, pull_requests, candidate_containers, harbor_runs TO anon;
+
+ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT ON TABLES FROM anon;