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

FunctionCallerAuth mechanismCan it publish?
news-adminStaff in the browser SPAUser JWT via AuthMiddleware, then ensure_staff_access() RPC resolves role news-admin/utils/context.ts:31Admins yes, authors submit only
news-apiServer-side producers (AI writers)X-API-Key header, hashed lookup _shared/newsApiKey.ts:73No. Drafts only, by absence of a route news-api/index.ts:30
news-publicCrawlers, feed readers, unfurlersNone. GET/HEAD only, published rows onlyRead only
Why three functions instead of one with branches? Each has a different CORS posture and a different failure surface. 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

1CORS gate, then 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-158
2buildContext 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
3Path is sliced after the function name, then destructured as [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,167
4Status changes are one RPC with a lookup table. STATUS_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
5Postgres errors map to HTTP in one place. _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-22

The 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

  1. 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 against STATUS_ACTIONS.
  2. Run the schema tests: cd src && npx supabase test db and find news-state-machine.test.sql in the output. Read one assertion that would fail if an author could publish.
  3. 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.