The previous checkpoint built the audience itself; this one makes the platform’s auth its own product rather than a Keycloak integration. Three seams closed:
Passwordless, built in
POST /audience/v1/{project}/magic-link records a single-use, SHA-256-hashed, 15-minute link and answers the
same HTTP 202 whether or not the email has an account — enumeration is a response-level property, so both
cases look identical. POST …/magic-link/{token} spends it once: an existing account signs in, an unknown
email becomes a site user in the project’s open self-assign role with an unusable password, and any other
outcome — used, expired, superseded, wrong project — is the same magic_link_invalid. Requesting a second
link retires the first, so an old link can never outlive the newest request. return_to must be an absolute
http(s) URL, so the link always lands on the site that asked.
There is no mail sender yet — that is the honest remaining gap. COLOPHON_DEV_MAIL stands in on loopback only:
it hands the link back in the response for development and demos, and the server refuses to boot with it set
on a public listener, because on a public bind it is an account-takeover switch, not a feature.
No required provider
An installation now runs entirely on built-in accounts. GET /auth/providers publicly answers whether an
external provider is configured, so the Studio’s sign-in card hides “Sign in with your organisation” instead
of offering a button that can only 503. colophon admin bootstrap --issuer password --subject [email protected] makes a
password account the first administrator — before this, the CLI demanded an issuer URL even on an install with
no provider. OIDC remains an adapter a deployment may add; it is no longer an assumption.
Auth the staff can see
Every visitor auth event is in the project’s append-only audit feed — audience.user_signed_up,
.user_signed_in, .user_signed_out, .magic_link_sent, .magic_link_spent — readable through the same
/audit endpoint staff already use. The actor is a shared urn:colophon:system identity (the pattern the
worker’s budget events already used), because audit_events.actor_id references staff identities and a site
user is not one. The write runs after the auth commits, best-effort by contract: auth correctness never
depends on the log, and the log never becomes a second auth path.
And the log has a home: the Studio’s new Audience page sits next to Members — a different credential
kind, a different page. It lists the project’s site visitors (role, last sign-in, disabled state), lets a
role holder change roles or disable an account, shows the audience roles, and surfaces a “Sign-in activity”
panel that filters the same /audit feed to audience.*. Members are staff; the Audience are your readers
— the Studio now shows both.
A usability pass followed (D-144): the sidebar’s catch-all “Project” group became People (Members, Audience — the staff/visitor split is taught by position now, not by a subtitle) and Structure (Content types, Migrations, Settings). “All projects” caps at 24 and asks you to search, because the dev database’s hundreds of test projects proved an unbounded grid is where projects go to hide. Zero-count badges went quiet, “Last published” admits “Never”, and the browser suite asserts the cap by finding its project through search — the intended behaviour, not a workaround.
Then the last gap closed (D-145): magic links now reach a real inbox. Services.mail holds an
optional Arc<dyn Mail> — a provider seam rather than a hardcoded sender — and
COLOPHON_MAIL_PROVIDER=resend + COLOPHON_MAIL_FROM + COLOPHON_RESEND_API_KEY builds the first
adapter: one POST to Resend’s emails endpoint under a bearer key and a ten-second deadline, with
COLOPHON_RESEND_ENDPOINT to move the address for relays or tests. The send is best-effort after the
link row commits: a refused send warns in the server log but can never change the 202 (the response
stays enumeration-proof) or undo the recorded link — asking again delivers a fresh one. Config is
all-or-none like COLOPHON_S3_*: a partial setup, an unknown provider, or a stray endpoint fails at
boot, not silently. The end-to-end proof runs the real server against a local receiver and asserts the
whole provider call — bearer, sender, recipient, subject, the link in both bodies — then spends the
emailed token into a real session, and a scripted provider-500 still answers sent. dev_mail still
wins when both are set, so development needs no provider at all. SMTP and operator-managed providers
land on the same seam when needed.
Still open: SMTP as a second provider, social/OAuth sign-in for visitors, and billing/subscription entitlements (deliberately deferred — roles are labels, not plans).