Engineering manualDatabase declarations
supabase-brand-studio-api-keys
Exact checked-in database declaration: docs/supabase-brand-studio-api-keys.sql
Source: docs/supabase-brand-studio-api-keys.sql. Declared objects: index:brand_studio_api_keys_user_idx, table:brand_studio_api_keys. This is repository DDL, not a claim about live deployment.
All database declarations · Data structures and lineage
-- Brand Studio — the "doorknob" round (owner-direction-2026-08-18):
-- service-account API keys, and per-pull idempotency.
--
-- ADDITIVE ONLY. This file creates ONE new table (brand_studio_api_keys) and
-- its policies, plus ONE new index on a table that already exists
-- (brand_studio_pulls, from docs/supabase-brand-studio-pulls.sql). It alters
-- no existing column, drops nothing, and backfills nothing — applying it to a
-- live project changes the behaviour of no existing query. Same posture as
-- docs/supabase-brand-studio-byo-keys.sql and docs/supabase-brand-studio-
-- open-beta.sql.
--
-- Two rows on src/local/upgrade-proposals.mjs (tier 0, provider in_house,
-- source owner-direction-2026-08-18) are what this file exists to serve:
--
-- api_keys_service_accounts — "A way to authorise a script or an agent
-- without handing it a human's login." The table below.
--
-- pull_idempotency_keys — "Certainty that a request sent twice ... produces
-- one report and one charge." The index at the bottom.
--
-- ======================================================================
-- PART 1 — brand_studio_api_keys
-- ======================================================================
--
-- ONE ROW PER ISSUED KEY. `bsk_live_<random>` is shown to the caller exactly
-- once, at creation (hosted-app/lib/api-keys-store.mjs's createApiKey); this
-- table never stores that value. It stores the SHA-256 digest instead
-- (src/local/api-keys.mjs's hashApiKey), which is a ONE-WAY function —
-- unlike brand_studio_byo_keys' AES-256-GCM ciphertext, there is no secret
-- anywhere that decrypts this column back to a usable key. See
-- src/local/api-keys.mjs's header for the full reasoning on why a service
-- account's identity is hashed rather than encrypted, and why that is a
-- different problem from a customer's provider credentials.
--
-- `last4` is stored separately, in the clear, for display — same reasoning
-- as brand_studio_byo_keys' last4: four characters identify a key to the
-- person who owns it and reconstruct nothing.
create table if not exists public.brand_studio_api_keys (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
-- A label the owner chose ("CI pipeline", "my agent"). Free text, cleaned
-- and length-capped by cleanKeyName() before it ever reaches this column.
name text not null default 'unnamed key',
-- THE WHOLE SCOPE VOCABULARY IS TWO STRINGS. Mirrors API_KEY_SCOPES in
-- src/local/api-keys.mjs exactly — keep these two lists in sync by hand,
-- there is no shared source between SQL and JS to generate this constraint
-- from (same caveat brand_studio_credit_ledger's `kind` check carries).
-- `<@` is Postgres's array-containment operator: every element of `scopes`
-- must be one of the two named here. A key created with an empty array is
-- refused by the APPLICATION before it reaches this insert (createApiKey's
-- no_valid_scopes reason) — the constraint below is the backstop, not the
-- first line of defence.
scopes text[] not null default '{}'::text[]
check (scopes <@ array['run_pulls', 'read_reports']::text[]),
-- SHA-256 hex digest, 64 characters. The ONLY form of the key ever
-- persisted. Unique so two keys can never hash-collide onto the same row —
-- a practical impossibility for SHA-256, stated here as a real constraint
-- rather than assumed.
key_hash text not null unique,
-- Last four characters of the plaintext, in the clear. Display only.
last4 text not null default '',
created_at timestamptz not null default now(),
-- Set by touchApiKeyLastUsed() on every successful authentication. Null
-- means "issued, never used" — a real and useful fact (a key nobody has
-- ever called might be safe to revoke), not an absence to hide.
last_used_at timestamptz,
-- Null while active. Set once, forever — there is no un-revoke, only
-- issuing a new key. A revoked key's row is kept rather than deleted, so
-- "when was this cut off" stays answerable after the fact.
revoked_at timestamptz
);
create index if not exists brand_studio_api_keys_user_idx
on public.brand_studio_api_keys (user_id);
-- The lookup findApiKeyByHash() performs on every request bearing an API-key-
-- shaped bearer token. `key_hash` is already unique above, which Postgres
-- backs with an index automatically — this comment exists so a reader does
-- not go looking for a second index that would be redundant.
alter table public.brand_studio_api_keys enable row level security;
-- ZERO CLIENT POLICIES — the identical posture brand_studio_byo_keys uses,
-- for the identical reason. RLS is enabled and NO policy grants anon or
-- authenticated anything, so a browser holding a user's own JWT cannot select
-- this table at all — not their own row, not anyone's. Only the service
-- role, which bypasses RLS, can read or write it, and the only code holding
-- that key is the server (hosted-app/lib/api-keys-store.mjs).
--
-- Reads are locked down too, same reasoning as byo-keys: findApiKeyByHash
-- must work for a caller who does not yet know who they are (it is resolving
-- IDENTITY from a hash), which is the opposite of what an auth.uid()-scoped
-- policy could ever answer, and the settings page renders from the app-level
-- listApiKeys() call, which the server already scopes to the signed-in user.
--
-- A policy added here later must be justified against that sentence.
comment on table public.brand_studio_api_keys is
'Service-account API keys ("bsk_live_..."), hashed SHA-256 (src/local/api-keys.mjs). RLS on with no client policies: service role only. Scoped to run_pulls and/or read_reports; resolveApiKeyContext (hosted-app/lib/comprehensive-pull.mjs) is the one reader.';
-- ======================================================================
-- PART 2 — per-pull idempotency
-- ======================================================================
--
-- ROW pull_idempotency_keys. "A retry that does not charge twice ... The
-- queue already leases jobs, so the dedupe has somewhere natural to live."
--
-- The key travels in `brand_studio_pulls.signals.idempotency_key` — a jsonb
-- field on a column that already exists (docs/supabase-brand-studio-
-- pulls.sql), not a new one, for the identical reason the queue envelope and
-- the pull tier ride there: it needs no migration beyond this index.
--
-- SCOPED TO THE ACCOUNT, not globally — the upgrade-proposals row is
-- explicit: "two customers must be able to send the same key without
-- colliding." The composite (user_id, ...) is what makes that true.
--
-- THE RACE THIS CLOSES. authorizeAndReserveComprehensivePull
-- (hosted-app/lib/comprehensive-pull-handler.mjs) checks for an existing row
-- with this key BEFORE inserting, as a fast path — but "check then insert" is
-- two statements, and two concurrent requests carrying the same key can both
-- pass the check before either has inserted. This partial unique index is
-- what makes the SECOND insert fail at the database with SQLSTATE 23505
-- rather than silently creating two pulls (and, eventually, two charges) for
-- one idempotency key — the identical shape
-- brand_studio_credit_ledger_idempotency_idx closes for the credit ledger,
-- applied here to the row a retried agent actually resends.
--
-- Partial ("where ... is not null") for the same reason every other
-- idempotency index in this repo is: most pulls carry no idempotency key at
-- all, and a plain unique constraint over a nullable expression already
-- treats NULLs as distinct — the predicate states the intent rather than
-- working around that behaviour.
create unique index if not exists brand_studio_pulls_idempotency_idx
on public.brand_studio_pulls (user_id, (signals ->> 'idempotency_key'))
where signals ->> 'idempotency_key' is not null;