Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 343 additions & 0 deletions dev-plans/supabase-pending-work-2026-08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
# Supabase: work that must be finished from a machine with project access

Written 2026-08-16, updated 2026-08-17. Everything below needs the Supabase CLI
logged in against the production project; none of it can be done from CI or from
a checkout alone.

Read task 1 first — it is the only item where production is currently in a
half-applied state, and it is losing data every hour it stays that way.

The underlying theme, worth holding in mind throughout: **`supabase/migrations/`
and the production database have drifted apart in both directions.** Objects
exist in production that the repository never defines (`is_admin()`,
`public.profiles`), and migration versions are recorded in production that the
repository does not contain. Every failure below is a symptom of that one
problem, and the work is as much about reconciling the two as about any
individual fix.

---

## Task 1 — finish landing the client_error_log hardening (PR #164, merged)

### What is already true

PR #164 is merged and Vercel has deployed `main`. The browser bundle in
production now calls `supabase.functions.invoke('report-client-error', …)`
instead of inserting into `client_error_log` directly.

That function does not exist yet, and the migration has not been applied.

### What that means right now

- **Client error reports are being dropped.** The invoke fails, the failure is
caught in `src/lib/errorReporting.ts` and logged to the console, and nothing
is stored. Nothing crashes — reporting is deliberately incapable of throwing —
but production errors are invisible.
- **The hole PR #164 exists to close is still open.** `anon` keeps its `INSERT`
grant on `client_error_log` until `20260816000000` is applied, so the public
anon key can still write unbounded rows straight to PostgREST.

**Reporting needs both halves — neither one alone restores it.** The function is
only a front door: it stores nothing by itself, it calls
`record_client_error()`, and that function is created by the migration. Deploy
the function without the migration and the RPC fails, the function swallows the
error and still answers `{"ok":true}`, and no row is written.

Do the function first anyway. It is a one-liner and nothing depends on it,
whereas the migration is entangled with the version mismatch described in 1d and
may take real work. Just do not read a successful deploy as reporting being
fixed — that is only true after 1d.

Neither problem is an emergency, but the window should be short.

### 1a. Link the project

Every `supabase db …` and `supabase migration …` command below acts on the
*linked* project. They select their target with `--linked` / `--db-url` /
`--local` and do **not** accept `--project-ref`, so link once up front:

```bash
supabase link --project-ref <production-ref>
```

The project ref is in the Supabase dashboard URL, or in `supabase projects list`.
Do not use a ref taken from a PR check link — those are ephemeral preview
branches and differ on every PR.

(`functions deploy` and `secrets set` do take `--project-ref`, and it is spelled
out below so those two steps work whether or not the link succeeded.)

### 1b. Deploy the Edge Function

```bash
supabase functions deploy report-client-error --project-ref <production-ref>
```

Verify it is reachable:

```bash
curl -s -X POST "https://<production-ref>.supabase.co/functions/v1/report-client-error" -H "Authorization: Bearer <anon-key>" -H "Content-Type: application/json" -d '{"message":"deploy smoke test","source":"/manual-check"}'
```

Expect `{"ok":true}`. That is all this proves. The function answers `{"ok":true}`
for every outcome by design — stored, throttled, malformed, or RPC-missing — so
it cannot tell you the row landed, and at this point the row will *not* have
landed, because `record_client_error()` does not exist until 1d. The end-to-end
check that does confirm storage is at the end of 1d.

If you want to see why nothing was stored, the function says so in its logs —
Edge Function logs live in the dashboard, under Edge Functions →
`report-client-error` → Logs. Expect a `[report-client-error] record failed`
line naming the missing function. (There is no `supabase functions logs`
subcommand; the CLI has `list`, `deploy`, `download`, `delete`, `new` and
`serve`.)

### 1c. Set the IP salt (optional)

```bash
supabase secrets set ERROR_LOG_IP_SALT="$(openssl rand -hex 32)" --project-ref <production-ref>
```

Skipping this is safe: the function falls back to the service role key as the
salt, which is unguessable and never leaves the server. Set it if you would
rather the rate-limit hashes not be derived from the service key. Changing it
later only resets the current hour's quota buckets.

### 1d. Apply the migration

Read the next section before running this — `db push` will probably refuse.

```bash
supabase db push --linked
```

The intent is to apply `20260816000000_harden_client_error_log.sql` and nothing
else: earlier versions are already recorded as applied and are not re-run,
including the historical files edited in PR #166.

#### Expect a version mismatch first

The `Supabase Preview` check has been failing on every push to `main` since at
least 7 August — before any of the recent work — with:

```
Remote migration versions not found in local migrations directory.
```

The production database has migration versions recorded in
`supabase_migrations.schema_migrations` that do not exist as files in
`supabase/migrations/`. Someone applied migrations directly against the project
and they were never committed. This is the same problem as `is_admin()` and
`profiles`, seen from the other side: the repository is not a faithful record of
production.

Two consequences worth being clear about:

- **Nothing has been applied automatically.** The GitHub integration has not
successfully synced `main` to production for weeks, so `20260816000000` is
certainly not applied — and possibly neither are others.
- **`db push` will hit the same wall** until the histories agree.

Start by seeing exactly where they diverge:

```bash
supabase migration list --linked
```

That prints local and remote versions side by side. For each version that is
remote-only, decide deliberately:

- **Capture it** — the right default. The migration represents real schema that
exists in production and is missing from the repo. `supabase db pull --linked`
writes the current remote schema into a new migration file; use it to recover
the definitions (this is also where `profiles` and `is_admin()` will come from,
so it doubles up with task 2).
- **Discard the record** — `supabase migration repair --linked --status reverted <version>`
tells the CLI to forget a remote entry. Only do this for versions you have
confirmed are obsolete or were superseded. It changes bookkeeping only; it
does not undo any schema change that migration made.

Do not reach for `migration repair` to make the error go away quickly. Marking
real, applied migrations as reverted is how the repo drifts further from
production, and the drift is what caused every problem in this document.

Verify the outcome. All four should hold:

```sql
-- 1. anon has no table-level or column-level INSERT
select grantee, privilege_type from information_schema.role_table_grants
where table_name = 'client_error_log' and grantee in ('anon', 'authenticated');
select count(*) from information_schema.column_privileges
where table_name = 'client_error_log' and grantee in ('anon', 'authenticated')
and privilege_type = 'INSERT';

-- 2. no INSERT policy remains
select * from pg_policies where tablename = 'client_error_log' and cmd = 'INSERT';

-- 3. the quota-checked path exists
select proname from pg_proc where proname = 'record_client_error';
select tablename from pg_tables where tablename = 'client_error_report_quota';

-- 4. reporting now works end to end — re-run the 1b curl, then:
select message, source, created_at from public.client_error_log
order by created_at desc limit 5;
```

Expected: `authenticated: SELECT` only; `0` column grants; no INSERT policy;
both objects present; and the smoke-test row appearing for the first time — this
is the check that proves reporting is restored, which the deploy in 1b could not.

Delete the smoke-test row afterwards if you care about a clean table.

### 1e. Confirm the direct path is actually closed

The point of the change. This must now fail:

```bash
curl -s -X POST "https://<production-ref>.supabase.co/rest/v1/client_error_log" -H "apikey: <anon-key>" -H "Authorization: Bearer <anon-key>" -H "Content-Type: application/json" -d '{"message":"should be rejected","source":"/direct"}'
```

Expect a permission-denied error, not `201`. If it succeeds, the migration did
not apply — recheck 1d before assuming otherwise.

---

## Task 2 — close the `public.profiles` gap in the migration history

### Background

The `Supabase Preview` check builds a database from `supabase/migrations/`
alone. It has been failing since 2 July. PR #166 fixed the first cause
(`is_admin()` was defined by hand in production and never entered the migration
history); the check now gets three migrations further and fails on the next
instance of the same problem:

```
ERROR: relation "public.profiles" does not exist (SQLSTATE 42P01)
At statement: 2
drop trigger if exists trg_log_role_change on public.profiles
```

`public.profiles` is an application table created by hand in the dashboard. It
is referenced by five migrations and defined by none:

- `20260613000001_add_admin_utility_usage_fn.sql` — first mention (inside a
function body, so it resolves lazily and does not fail at apply time)
- `20260702000000_create_admin_audit_log.sql`
- `20260702000001_account_self_service_fns.sql`
- `20260702000002_admin_analytics_fns.sql`
- `20260702000003_role_change_audit_trigger.sql` — first hard failure, because a
trigger names its table at creation time

This is why the check fails on exactly the PRs that touch `supabase/` and is
skipped on all others: **no migration has been validated by CI since 2 July.**

### Why this needs the real database

The migrations only reveal the columns they happen to touch — `id`, `email`,
`full_name`, `username`, `role`. The real table certainly has more, and it
certainly has RLS policies and grants that the migrations never mention.

Writing `create table if not exists public.profiles (…)` from those five columns
would not affect production, where the table already exists — but every preview
branch and any future rebuild would get a truncated, probably unprotected
`profiles`, and the check would go green while describing a schema that does not
exist. A green check that lies is worse than the red one.

### Dump the real definition

```bash
supabase db dump --linked --schema public > /tmp/public-schema.sql
```

From that file, extract everything about `profiles`, not just the table:

- `CREATE TABLE public.profiles (…)` with all columns, defaults and constraints
- indexes
- `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
- every `CREATE POLICY … ON public.profiles`
- `GRANT` / `REVOKE` statements naming `profiles`
- any trigger or function attached to it that is not already in
`supabase/migrations/` (`handle_new_user` and similar are common)

Also dump `is_admin()` itself while you are there and compare it against the
deny-all stub PR #166 adds — the stub is only a fallback for fresh databases,
but knowing the real definition is worth having written down:

```bash
supabase db dump --linked --schema public | grep -A 20 "FUNCTION public.is_admin"
```

### Where to put it

Follow the pattern PR #166 established: bootstrap the object in the earliest
migration that references it, guarded so production is never touched.

That is `20260613000001_add_admin_utility_usage_fn.sql` — the same file, which
already carries the `is_admin()` guard and is the first to mention `profiles`.
Add a guarded `create table if not exists public.profiles (…)` plus its RLS,
policies and grants above the existing function definition.

Editing an applied migration is safe: Supabase records migrations by version and
will not re-run them, and `if not exists` means the block is inert against a
database that already has the table.

### Verify locally before pushing

No Supabase access needed for this part — it is the same harness used to verify
PR #166:

```bash
docker run -d --name mig-check -e POSTGRES_PASSWORD=test -p 55440:5432 postgres:17-alpine
```

Scaffold **only** what Supabase itself provides, so anything missing from the
migration history shows up as a failure rather than being papered over:

```sql
create role anon; create role authenticated; create role service_role;
create schema if not exists auth;
create table auth.users (id uuid primary key default gen_random_uuid(), email text);
create function auth.uid() returns uuid language sql stable as $$ select null::uuid $$;
```

Do **not** create `profiles` or `is_admin()` in the scaffold — those are the
things under test. Then apply every migration in filename order and confirm all
of them succeed. When that passes with nothing but the four lines above, the
preview check will pass too.

---

## Task 3 — make the check mean something

Once it goes green, make `Supabase Preview` required on `main` in the branch
protection settings. It is the only thing that would have caught any of these
gaps, and while it fails it silently protects nothing.

Note that the check reports two different failures depending on where it runs,
and both must be fixed:

- **on a pull request** it builds a database from `supabase/migrations/` alone —
this is the one that fails on `profiles` (task 2);
- **on `main`** it syncs to production — this is the one that fails on the
version mismatch (task 1d).

---

## Status as of 2026-08-17

| Item | State |
|---|---|
| PR #164 — client_error_log via Edge Function | merged; **function not deployed, migration not applied** |
| PR #165 — camera scoped to QR Master | merged; smoke-test QR Master scanning on `/utilities/qr-master/` and `/ru/utilities/qr-master/`, plus the whisper microphone on a non-English page |
| PR #166 — `is_admin()` bootstrap order | merged; PR-side preview now gets past it and fails on `profiles` (task 2) |
| PR #169 — related tools use the catalog card | merged |
| PR #168 — nanoid pinned to a patched 3.x | merged |
| PR #167 — this document | open |
| PR #163 — dompurify 3.4.12 → 3.4.13 | merged |
| dependabot alerts | **none open.** dompurify closed by #163, nanoid by #168, and the two `image-size` highs dismissed as not-used (build-time only, no patch exists) |
| `Supabase Preview` on `main` | failing since ≥ 7 August on the version mismatch — nothing is syncing to production automatically |

Everything that could be finished from a checkout is finished. What is left in
this document is exactly the part that needs project access, and task 1 is the
one carrying a cost while it waits.
Loading