Files
Ekklesia/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md
T
ekklesiaandClaude Opus 5 80189c8ae2
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
feat: give display devices their own paired identity
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page.
Together, the only way to light a television in a foyer was to sign a
human being into it and leave that session running on a screen anyone can
walk up to: a person's credentials on a wall, a person's session as the
thing signage depends on, and the display going dark when they are
revoked or their session simply expires.

A screen is not a person. Ekklesia now recognizes three principal classes
— anonymous, authenticated human, and managed device — and a device is a
first-class principal with its own credential and its own explicit
permissions. It has no email, no password, and no row in the identity
tables, and it satisfies the `authenticated` baseline never, so every
route that settles for "somebody is signed in" stays closed to it.

Device permissions live in their own table because
ekklesia_core_grant.user_id is a non-null foreign key into the user table
and making it polymorphic would rewrite the platform's central
authorization record to serve one narrow case. That is a schema decision,
not a second decision point: the evaluator gained one branch, so a device
is allowed or denied by the same function, with the same deny-by-default,
as anyone else — with no owner path it could reach and no baseline that
stands in for a grant.

The six-digit code is not the credential. It is printed across a screen in
a public hallway, so it is treated as public: it identifies a request to
an administrator and is worth nothing alone. Collecting the credential
also requires a 256-bit claim secret held only in the requesting browser's
HttpOnly cookie, and approving requires the restricted core.devices.manage
permission. Without that split, reading a code off a wall would hand over
the screen's identity.

The permission a pairing asks for is read from the module's validated
display declaration, never from the request, so a browser cannot ask to be
granted core.devices.manage or another module's permission. Approval
grants exactly that one permission.

Credentials are stored only as SHA-256; the raw value exists in the
Set-Cookie that issues it and the device's own cookie jar, and is never
logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so
the module display code running in that same page cannot read it.
Revocation is a server-side column checked on every principal resolution,
and re-pairing mints a new identity so an old credential can never come
back. State transitions are conditional updates, so one code cannot be
approved twice or claimed into two credentials under a race.

/api/v1/principal still reports a device as anonymous: it is what the
Dashboard uses to decide whether a person is signed in. Displays read
their viewer from the descriptor instead, which already performed the
authorization — so displays are descriptor-first while the Dashboard stays
principal-first, and neither infers an identity by elimination.

Proven end to end against real PostgreSQL and real HTTP: an unpaired
screen shows a code, an owner approves it, the device opens the display
descriptor and a realtime stream, and revocation closes both while the
device still holds its cookie. An already-open SSE stream is bounded by
realtime's existing five-minute reconnect rather than closed instantly,
and that bound is documented rather than called immediate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:41:03 -05:00

11 KiB

0036: A display device is its own principal, paired by an administrator

Context

ADR 0033 gave Ekklesia a chrome-free display host, and ADR 0034 gave it a login page. Put together, the only way to light up a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to.

That is not a display model. It means a person's credentials live on a wall, a person's session is what a screen depends on, and revoking that person — or their session simply expiring — takes the signage down. The alternatives are worse: a public unauthenticated display, a hardcoded token, or a credential in a URL.

A screen is not a person, and pretending otherwise is the problem.

Decision

Ekklesia recognizes three principal classes: anonymous, authenticated human, and managed device. A device is a first-class principal with its own identity, its own credential, and its own explicit permissions.

screen opens /display/announcements → no identity → shows a short code
administrator types the code in the Dashboard, names the screen, approves
screen collects a credential → device principal → display and realtime work
administrator revokes → the screen loses access and shows a code again

Devices are not users

There is no device row in the identity tables and no foreign key from a device to a user. A device has no email, no password, and no human session.

This matters beyond tidiness. Every "is a person signed in?" question in the platform is answered by isAuthenticatedPrincipal, and a device that satisfied it would inherit whatever a signed-in human may do. So a device satisfies the authenticated baseline never — not with one permission, not with ten. The only thing that makes a device allowed is an explicit permission granted to that exact device.

Device permissions are their own table, and the evaluator is still the only judge

ekklesia_core_grant.user_id is a non-null foreign key into the user table. Generalizing it into a polymorphic subject would rewrite the platform's central authorization record — its relationship-derived grants, its audit semantics, its indexes — to serve one narrow case. Device permissions therefore live in ekklesia_core_device_permission.

That is a schema decision, not a second decision point. createAuthorizationEvaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else. There is no if (device) bypass, no owner check a device could reach — a device is never a platform owner — and no baseline that stands in for a grant. A deployment that composed no device support at all simply denies every device.

The pairing code is not the credential

Three secrets are involved and conflating any two of them would break the model:

value who sees it what it does
pairing code (6 digits) anyone who can see the screen identifies the request to an administrator
claim secret (256 bits) only the requesting browser, HttpOnly proves "I am the screen that asked"
device credential (256 bits) only the paired browser, HttpOnly is the device's identity

The code is printed across a television in a public hallway. It is therefore treated as public: it is stored in the clear, and knowing it is worth nothing on its own. Approving it requires an administrator holding core.devices.manage, and collecting the resulting credential requires the claim cookie, which no onlooker has. A flow that issued a credential to whoever presented the code would hand a screen's identity to anyone who read it off the wall.

Six digits are therefore not carrying the security of this design. They are bounded anyway: ten minutes, single-use, one live request per code enforced by a partial unique index, and an administrator in the loop.

The permission comes from the display, never from the request

A pairing request names only the module whose display the screen is sitting on, and even that is not taken at its word — the module is looked up in the publication registry and the permission is read from its validated display declaration. A browser cannot ask to be granted core.devices.manage, another module's permission, or anything else. Approval grants exactly the one permission the host derived, never a bundle.

State machine

pending → approved → consumed, plus expiry derived from expires_at.

Expiry is deliberately not a fourth state: nothing has to run for a code to stop working, so there is no sweeper whose failure would leave live codes lying around. Each transition is a conditional UPDATE — approval matches only a pending, unexpired row, and consumption matches only an approved one — so approving twice, approving something expired, and two screens racing to claim one approval all affect zero rows rather than racing. Exactly one credential can ever come out of one code.

The credential is hashed, and revocation is server-side

The device receives an opaque 256-bit credential in an HttpOnly cookie; the server stores only SHA-256 of it. A fast hash is correct here — unlike a password, there is nothing to brute-force in CSPRNG output, and a slow KDF would only add latency to every request a display makes. The raw value exists in exactly two places: the response that sets the cookie, and the device's own cookie jar. It is never logged.

Revocation is revoked_at on the device row, checked on every principal resolution. It is not a request that a remote browser discard anything: a revoked screen keeps its cookie and stops working.

Cookies

Two host-owned cookies, named nothing like Better Auth's: ekklesia_device_pairing (short-lived, cleared on use) and ekklesia_device_session. Both are HttpOnly — module display code runs in this very page and must never be able to read the credential — SameSite=Strict, host-only, Path=/, and Secure whenever the deployment's own base URL is https. Nothing about the credential is ever returned in JSON, put in a query string, or written to localStorage.

A human session always wins. The device cookie is not even read when Better Auth resolves a person, so an administrator who opens a display on their laptop stays themself.

/api/v1/principal stays human-only

A device is reported as anonymous there, deliberately.

That endpoint is what the Dashboard shell uses to decide whether someone is signed in, and its shape — "not anonymous means logged in" — was load-bearing in the shell before this pass. Returning a device from it would have made a television a Dashboard user. The shell's test is now === "authenticated" as a second lock, but the first lock is that the endpoint never says "device" at all.

The display host instead reads its viewer from the descriptor, which is the display's actual authorization boundary and already performed the check. So displays are descriptor-first while the Dashboard remains principal-first, and neither has to infer an identity by elimination.

The display page is served to anyone; the descriptor is not

An unpaired screen has no identity, so a permission gate on the page would leave a new television with nowhere to render a code. The page is the same inert, module-agnostic bytes for every display — no module id, no data, one already-public script URL — and every authorization decision stays in the descriptor. What this does reveal to an anonymous caller is that a module with a display exists at that path, which any pairing flow must reveal to the screen it is pairing.

Session lifetime

Device credentials last 180 days; human sessions are unchanged. A screen mounted near a ceiling should not need re-pairing on a fortnightly cycle. What makes a long horizon safe is that it is not the security boundary — the server re-checks revocation on every request — and the expiry exists so an abandoned, forgotten screen eventually stops being an identity at all. lastSeenAt is updated at most every 15 minutes, in SQL as well as in the resolver, so a display polling realtime does not become a write load.

Consequences

Revocation is prompt, but not instantaneous, and this ADR says so

A revoked device fails its next HTTP request immediately, and its next realtime connection immediately. An already open SSE stream is not torn down at the moment of revocation: realtime already forces a reconnect every five minutes (ADR 0032), so the worst case is a bounded five-minute window during which a screen keeps receiving a channel it has just lost. Closing live streams on revocation would mean reaching into the realtime registry by principal, which is a change to a subsystem this pass has no other reason to touch. The bound is documented rather than described as instant.

Re-pairing creates a new identity

A revoked screen that is paired again becomes a new device row with a new credential. The old credential is never reactivated, and the revoked row stays in the list as evidence. Reactivating the original would mean a revoked credential could come back, which is exactly what revocation must prevent.

What was deliberately not built

QR codes (a future scanner can encode the same short code — the backend protocol does not change, and a QR must never carry the credential, an admin token, or a session), per-device content targeting, device groups, health monitoring, remote control, and mobile dashboards. The pairing API is not desktop-specific, so a future mobile dashboard host can consume it unchanged.

Ekklesia Lite has none of this

Lite is a minimal trusted-network module runtime: module loading, config, database, storage, HTTP, realtime, and UI hosting. It deliberately provides no human authentication, no permission enforcement, and no managed device identity or pairing — a Lite-hosted display simply runs on a trusted local network. Device identity is a full-Ekklesia capability, and nothing here belongs in a future Lite contract. No module code branches on which host is running it.

Module UI is untouched

A module's display receives a principal and its permissions, exactly as before. The principal union gained a device variant so a module can render honestly for a screen with nobody behind it, and that is the whole change — there are no pairing controls in ModuleUiContext, and a module cannot approve, list, or revoke anything.