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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ce4fe03fe
commit
80189c8ae2
@@ -0,0 +1,192 @@
|
||||
# 0036: A display device is its own principal, paired by an administrator
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-24
|
||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
||||
[0032](0032-native-module-realtime-transport.md),
|
||||
[0033](0033-display-kiosk-host-contract.md),
|
||||
[0034](0034-host-owned-browser-authentication.md),
|
||||
[0035](0035-initial-platform-owner-bootstrap.md)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md) gave Ekklesia a chrome-free display host, and
|
||||
[ADR 0034](0034-host-owned-browser-authentication.md) 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.
|
||||
|
||||
```text
|
||||
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.
|
||||
Reference in New Issue
Block a user