News CMS
Lesson 14 · about 15 minutes · three functions, one schema, one state machine: why an article can only become public through one door.
The editorial news system is the newest subsystem and the cleanest example of the repo's current conventions: everything lives in its own news schema, RLS is the authorization layer, every write goes through a Postgres RPC, and three edge functions differ only in who they resolve the caller to be. Do not confuse it with public."Stock_News_Articles", which is third-party market news from Massive (lesson 6).
Three doors, one room
| Function | Caller | Auth mechanism | Can it publish? |
|---|---|---|---|
news-admin | Staff in the browser SPA | User JWT via AuthMiddleware, then ensure_staff_access() RPC resolves role news-admin/utils/context.ts:31 | Admins yes, authors submit only |
news-api | Server-side producers (AI writers) | X-API-Key header, hashed lookup _shared/newsApiKey.ts:73 | No. Drafts only, by absence of a route news-api/index.ts:30 |
news-public | Crawlers, feed readers, unfurlers | None. GET/HEAD only, published rows only | Read only |
news-admin requires a valid Origin like every browser-facing function. news-api deliberately skips the origin check because API keys are never sent by a browser on a user's behalf news-api/index.ts:20. news-public uses publicReadCorsHeaders() with * because a crawler presents neither a JWT nor an Origin config.toml [functions.news-public]. Merging them would mean one function holding three contradictory CORS rules.The admin request path
AuthMiddleware. Same shape as lesson 1, but note withCors is applied to every response, because a browser cannot read even a 403 without the allow-origin header. Local Kong adds one, prod does not. news-admin/index.ts:153-158buildContext makes two clients. A userClient (service key plus the user's Authorization header, so auth.uid() is the caller) and a bare serviceClient. The role comes from calling news.ensure_staff_access() as the user; it also auto-provisions staff from news.allowed_email_domains on first use. Reading news.staff directly would miss that step. news-admin/utils/context.ts:22-35[resource, id, action]. UUIDs are regex-checked before any query so a malformed id is a 404, never a database error. news-admin/index.ts:44,167STATUS_ACTIONS maps submit→in_review, approve/publish→published, reject/restore→draft, archive→archived. The handler calls news.set_article_status(id, to, note); the SQL function owns the legal transitions and raises 42501 for an author trying to publish. news-admin/index.ts:38-41 · migrations/20260910120000_news_state_machine.sql:83-115_shared/newsRpc.ts turns SQLSTATE 42501 into 403, 40900 into 409 (optimistic lock via lock_version), P0002 into 404. Shared so the JWT path and the API-key path can never answer differently for the same error. _shared/newsRpc.ts:12-22The state machine
Four statuses: draft → in_review → published → archived, with draft → published as an admin fast path. It is an enum type, news.article_status, and the transitions live in SQL, not TypeScript. The RLS update policy on news.articles lets an author touch only their own rows while the status is draft or in_review migrations/20260910120000_news_state_machine.sql:20,38. A published slug is immutable (slug_locked), and the unique index on slug excludes archived rows so a slug can be reused after archival migrations/20260908120000_create_news_schema.sql:129,132.
API keys without a secrets table you can read
A key looks like wsb_news_<uuid>_<64 hex>. The embedded UUID makes lookup a primary-key hit; only the SHA-256 of the secret is stored. Comparison is constant-time, and every failure mode (missing, malformed, unknown, inactive, mismatched) collapses to one 401 so an attacker cannot enumerate. The plaintext is held in Vault and revealed only through news.reveal_api_key, which is why news.api_keys.secret_id points at vault.secrets _shared/newsApiKey.ts:15-27,58-65 · migrations/20260908120000_create_news_schema.sql:89.
Media and featured images
The news-media bucket has public read and no write policy at all. The only writer is the service role inside handleUploadMediaRequest, so the 8 MB and JPEG/PNG validation there cannot be bypassed _shared/newsMedia.ts:1-8 · migrations/20260910130000_news_media_bucket.sql:1-5. Articles without an image get one rendered on the fly: a pre-built RGBA template plus the company icon, composed with resvg WASM. The template and WASM binary are shipped via static_files in config.toml, which is the only way an edge function can read a non-code asset at runtime _shared/newsFeaturedImage.ts:1-24 · config.toml [functions.news-admin].static_files.
Rendering is capped at one at a time per worker; a busy worker returns the logomark instead, to stay under the edge runtime's CPU soft limit _shared/newsFeaturedImage.ts:40-46.
The draft alert
An AFTER INSERT trigger on news.articles posts draft and published counts to Slack. Its first version read a Vault secret that was never created and stayed silent for weeks. The current version relays through private.post_to_edge_function('/functions/v1/notify-slack', …) inside an exception block, so a failed alert can never roll back the article insert migrations/20260925104500_route_slack_alerts_via_edge_function.sql:19-33. That story is lesson 16.
Check yourself
Do this in the repo
- Read the transition guard:
sed -n 83,130p src/supabase/migrations/20260910120000_news_state_machine.sql. Write down every legal(from, to)pair before checking againstSTATUS_ACTIONS. - Run the schema tests:
cd src && npx supabase test dband findnews-state-machine.test.sqlin the output. Read one assertion that would fail if an author could publish. - With the stack served,
curl -i http://localhost:54321/functions/v1/news-public/feed.xml. No headers at all. Note which CORS header comes back and why it is*.
Primary source
docs/NEWS_CMS.md is the design record and explains every cut from the original RFC. For the authorization pattern, Supabase: Row Level Security.
Next lesson: 15, Email: the auth hook that replaces Supabase's default emails, and the waitlist trigger that reuses the same function with a different auth model.
Ask your teacher in the terminal: "walk me through news.ensure_staff_access" or "what does lock_version protect against" are good next questions.