Files
Ekklesia/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md
T
ekklesiaandClaude Opus 5.5 300d9eab51
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s
Pair displays at the platform's own /display/setup, with or without modules
Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:43:02 -05:00

195 lines
11 KiB
Markdown

# 0036: A display device is its own principal, paired by an administrator
- Status: Accepted
- Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Pairing a platform display with no module decided by: [0065](0065-display-setup-phone-forms-and-drawer-scroll-lock.md)
- 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.