Skip to content
Merged
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
4 changes: 1 addition & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<handle>` 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/<handle>` 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
Expand Down