The CMS had staff authentication from the beginning — editors and operators sign in through Keycloak or email-and-password, and memberships gate the management API. What it did not have was the feature the product spec describes as “membership / gated content”: a project’s audience. Site visitors are a different kind of credential entirely — they hold no membership, call no management route, and the only thing they are ever authorized to do is read published content. This checkpoint adds that fourth credential kind, end to end.
What I built
- Site users. Visitors sign up and sign in at
POST /audience/v1/{project}/{signup,signin}with an email and Argon2id password hash — the same password discipline staff accounts use. Sessions are opaque tokens, hashed at rest, with a 30-day expiry, 7-day idle death, a 16-session bound per user, sign-in rate limiting (8 misses locks for 15 minutes), and individual revocation. Signup may take only aself_assignrole — a project can mark itsmemberrole open so visitors join it themselves, whilevipstays staff-assigned. - Audience roles. Staff with the new
audience.manageaction (owner roles hold it; the migration backfills existing ones) create roles, manage users and their sessions, and everything lands in the audit log. - A visibility gate on content. Drafts, revisions and publications carry
visibility:public,authenticated(any live site session) orrole:<name>(a session whose user holds that audience role).?visibility=sets it on entry writes — validated as aVisibilityand, forrole:, against the project’s live roles — and a release publishes the gate its pinned revision carried. The gate is reproducible state, like everything else a revision pins. - Enforcement inside the read. The
publicationsRLS predicate does the deciding: an anonymous delivery transaction seespubliconly; a member’s transaction setsapp.audience_roleto the role the session lookup just read and sees public + authenticated + matchingrole:rows. A role change or a disable takes effect on the very next read — nothing to invalidate, no cache or token carrying a stale answer. - Cache discipline. A member request resolves before the generation-keyed delivery cache and always
bypasses it:
Cache-Control: private, no-cache,Vary: Authorization, Cookie,Colophon-Cache: bypass, its own ETag. The shared cache’s keys name no role, so a gated row can never be served to or stored for a stranger — and anonymous reads keep their cached fast path untouched. - An honest demo. The Draft (the blog demo site) runs the whole flow server-side:
/signin,/api/signin|signout|signupand/members/*render per-request under Astro’s node adapter, keep the session in a first-party cookie, and read delivery asrole:memberover seeded gated posts. The other four sites — docs, recipes, news, photography — stay fully static and can only ever see public rows, which is exactly the cross-site isolation the feature promises.
What proved it
- The RLS is proven at Postgres.
crates/colophon-db/tests/audience.rsruns five stack tests against the real role and policy: anonymous sees public only, a member sees their role’s rows, a disabled user degrades to anonymous, a session past expiry is dead, cross-project users see nothing of each other. The database is the test, not a mock around it. - The live flow runs. On the dev stack: the seed created the
member(open) andvip(closed) roles plus[email protected]through the staff API, published 54 entries (51 public, 1 authenticated, 2 role:member), and the rebuilt blog signs in, redirects to/members/and lists exactly the three gated posts — while an anonymous visit to the same URL redirects to sign-in. Anonymous collection reads returncolophon-cache: hit; member reads returnColophon-Cache: bypasswithCache-Control: private, no-cache. - Load-tested.
perf/k6/audience.jsdrove 3,000 requests through signup, signin,meand member-vs-anon collection reads with zero failures, p99 37 ms, all thresholds held — anonymous reads still hit the cache, member reads still bypass it. - Revocation and expiry work. Dead sessions degrade to anonymous rather than refusing public reads; a revoked session stops seeing gated rows on the next request, not the next deploy.
The honest part
- Roles are labels, not plans. The spec’s membership template names billing, subscriptions and entitlement
expiry — those are real features and deliberately deferred. A gate says who may read; charging for access is
a separate design that builds on this one without changing what
role:means. - Bearer credentials are the documented form for cross-origin sites (the demo sites use them): no CSRF echo needed. The cookie pair exists for same-origin embedders and gets the double-submit treatment on its one write.
- A missed gate fails closed. Because the predicate lives in the RLS policy rather than a WHERE clause the app might forget, a route that resolves the wrong audience gets fewer rows, never more — the failure mode is under-delivery, which shows up, rather than leakage, which doesn’t.
- Migrations renumbered in flight. This branch started life as migration 0050/D-136 before two other slices landed first; it ships as migration 0052/D-137. Numbers are cheap; collisions in a shared ordering are not.
Publishing now rebuilds the sites
The gap between “published” and “readers see it” used to be a manual step: publish in the Studio,
then remember to rebuild the demo sites by hand. That gap is closed with the product’s own
machinery. A website connection — the project’s webhook destination — points at a small build
hook that answers each release announcement 202 at once, builds the newest announced generation,
and leaves the running sites untouched when a build fails. Publishing in the Studio now updates
all five sites without anyone running anything: verified end to end, a real changeset published as
release 3 reached the hook eleven seconds later and all five sites rebuilt to generation 3 with
the new post served.
The same slice made The Draft a multi-collection site: it renders posts for the running argument
and articles for the longer pieces, both through the same delivery API from the same pinned
release. Release 4 (two articles) was published and the sites rebuilt themselves to generation 4
with the new section live.
Live updates: publishing reaches an open browser with no rebuild
The rebuild hook closed the publish-to-site gap, but a static site still has to be rebuilt. The stronger model, the one Convex made familiar, is push: the server tells clients what changed and they re-read. Colophon already had the two ingredients, a monotonic release generation and a commit-fed hint bus, so the delivery side got its own stream.
GET /content/v1/{project}/{environment}/events is an anonymous server-sent-events stream that
says only that published content changed. It carries no ids, no kinds and no collections, so a
gated entry’s existence cannot leak through it; a reader only ever learns to re-read what its own
credentials already cover. Only release hints are forwarded (draft saves and delivery-job churn
stay off it), a burst coalesces into one frame, and the stream is bounded by the same per-project
and per-server caps as the staff bus.
The Draft now renders its public pages per request and keeps one upstream connection, fanned out
to every open tab through its own /api/live. One CMS stream serves every visitor of the site,
not one per visitor. Verified end to end: a real release published, the open stream pushed
generation 7 then 8 within a second, and the page served the new post at 200 with no rebuild
anywhere in the path. The other four sites stay prerendered and keep the webhook rebuild, so the
demo now shows both delivery models honestly side by side: a static site pins one release, a live
site renders the current one.
What is next
P7 continues on the strength this lands: the remaining performance work from the five-site load picture, the 24-hour soak still running, then the human gates — the five-editor usability sessions (the sites and the members flow are the test surface), the independent install, licence confirmation and the v0.1 tag.