diff --git a/AGENTS.md b/AGENTS.md index ae6ce32..bdad617 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -342,11 +342,9 @@ Keep the tree this thin until something needs otherwise. What exists: the local Supabase stack, the three clients in `src/lib/supabase/`, generated types, and the environment wiring. An earlier SQLite (`better-sqlite3`) setup was removed, and a Drizzle/Postgres one was scaffolded and stripped back out, before this. -**There are seven migrations.** The first creates the `bluehex_admin` role, the `public.admins` list and the `custom_access_token_hook` that stamps the role onto an access token — the thing every later policy and grant refers to, and it holds no product data. The second is the profile core: `practitioner_contacts`, `practitioners` and `practitioner_review_notes`, their column-scoped grants, their policies, `practitioners_guard`, and the `approve_practitioner()` / `reject_practitioner()` RPCs. The third is the two catalogues (#89): `credential_catalogue` and `service_catalogue`, both Bluehex-owned reference data, whose read grants go to `anon` and `authenticated` by named column and whose write grants go to `bluehex_admin` alone — that omission is the whole of "a practitioner cannot invent a credential" and of "a custom service never becomes a filter chip". `service_catalogue` seeds its six labels from `src/lib/practitioners.ts`; `credential_catalogue` ships empty, because a wrong credential label is permanent in migration history while an empty catalogue is fixed by an `insert`, which is that table's sanctioned correction path anyway. The real list now exists and is not in that migration: the 24 confirmed Claude credentials are the canonical record in `supabase/seed/credential-catalogue.json` and load through `supabase/seed.sql`, which runs on any stack somebody boots — `pnpm db:reset` locally, `supabase start` in the `Schema` workflow — and never against the hosted project. Where they are permanently housed is still open, which is the whole reason they are somewhere reversible rather than in migration history; `tests/db/credential-catalogue-seed.test.ts` stops the JSON and the loader drifting apart. The fourth splits that table's `source` into `kind` and `platform` and adds `course_url` (#103) — two axes that vary independently, which is why the replacement constraint is `unique (kind, platform, label)` on all three. The fifth is `practitioner_credentials` (#50): the table the badge attests to, its column-scoped grants, its three policies, `credentials_guard`, `catalogue_guard` with `correct_catalogue_entry()`, `clear_profile_verification()` and its three triggers, and `set_credential_verified()`. The sixth is `practitioner_services` (#90): what a practitioner offers, one row per service, with its column-scoped grants, its three policies, `practitioner_services_cap` and `set_updated_at`. It is the one child table with **no guard**, because it carries no attested column — nothing there is Bluehex's assertion, so there is no `OLD` to pin and no badge to clear, and editing what you offer never touches verification. The seventh is `practitioners.handle` (#119): the public identifier as a `not null unique` column, generated by `new_profile_handle()` as a column default — eight characters of Crockford base32, packed by hand because Postgres `encode()` has no `base32` — with a format check, a `select` grant to `anon` and `authenticated`, and `practitioners_guard` replaced to pin it. It replaced `id.slice(0, 6)`, which was computed in TypeScript, enforced by nothing, and resolved with `.find()`, so a collision served the wrong practitioner's profile with their badge on it rather than erroring. `/p/` is the whole URL now; the slug and its canonical redirect are gone. - **The first query landed in #53**, and it is the public one: the home page reads approved profiles with their credentials and services, `/p/` reads one of them against the whole credential catalogue, and `/contact?about=` resolves a name. All of it goes through `src/lib/directory.ts` and the anonymous client, so every row that comes back is a row row level security decided a visitor may see — nothing filters on `status`, because `anon` cannot read it and the policy is the filter. Before that a health-check table existed briefly to prove the connection and was taken back out before it was ever committed, because it would have sat in the migration history permanently, describing a table dropped a fortnight later, to prove something the first real query proves for free. -What does not exist yet: `withdraw_profile()` and the erasure path (#52). `credential_catalogue.updated_at` **is** maintained on update as of #50 — `catalogue_guard` is what bumps it, its body reads `practitioner_credentials`, and a plpgsql body resolves its names at call time, so it could not be written before that table existed. **Auth landed with #83**: magic link only, so there is no password and no password reset; `@supabase/ssr` carries the session in cookies; `src/proxy.ts` refreshes it; and the `bluehex_admin` claim is read by the application. What that unblocks is that every policy in the spec is written against `auth.uid()`, and `auth.uid()` now resolves for a request the app makes on a signed-in person's behalf. Self-service writes through one as of #14, in two halves: the review queue at `/admin` (#122) and the profile editor at `/profile`, which reads the practitioner's own rows through `my_profile()` and `my_credentials()` and saves them with a Server Action. What is left of that ticket is the claim path for a curated profile, which is still an admin pasting an account id. The rest of this section is the contract for building those — treat it as binding, not as a description of current state. +What does not exist yet: `withdraw_profile()` and the erasure path (#52). `credential_catalogue.updated_at` **is** maintained on update as of #50 — `catalogue_guard` is what bumps it, its body reads `practitioner_credentials`, and a plpgsql body resolves its names at call time, so it could not be written before that table existed. **Auth landed with #83**: magic link only, so there is no password and no password reset; `@supabase/ssr` carries the session in cookies; `src/proxy.ts` refreshes it; and the `bluehex_admin` claim is read by the application. What that unblocks is that every policy in the spec is written against `auth.uid()`, and `auth.uid()` now resolves for a request the app makes on a signed-in person's behalf. Self-service writes through one as of #14, in two halves: the review queue at `/admin` (#122) and the profile editor at `/profile`, which reads the practitioner's own rows through `my_profile()` and `my_credentials()` and saves them with a Server Action, whose whole effect on the two child tables travels through `apply_profile_children()` in one transaction (#128). What is left of that ticket is the claim path for a curated profile, which is still an admin pasting an account id. The rest of this section is the contract for building those — treat it as binding, not as a description of current state. - **Target is [Supabase](https://supabase.com)** — Postgres, plus the auth that comes with it. Local development runs the Supabase CLI stack; deployed is a hosted Supabase