feat: give display devices their own paired identity
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s

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:
ekklesia
2026-08-23 23:41:03 -05:00
co-authored by Claude Opus 5
parent 3ce4fe03fe
commit 80189c8ae2
52 changed files with 5065 additions and 113 deletions
@@ -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.
+1
View File
@@ -44,3 +44,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
@@ -483,6 +483,18 @@ The permission must have an `authenticated` or `restricted` baseline: public/dev
is still intentionally unsolved. The display artifact must be distinct from every other configured
entrypoint. Primary UI, display UI, both, and neither are all valid module shapes.
Phase 7B pass 2 makes those screens principals in their own right
([ADR 0036](decisions/0036-managed-device-identity-and-pairing.md)). The platform recognizes
anonymous, authenticated human, and managed device. A device is deliberately not a user — no identity
row, no password, and no satisfaction of the `authenticated` baseline — and its authority is decided
by the one authorization evaluator, from an explicit permission granted to that exact device.
Pairing is administrator-approved: a screen shows a short, public code that identifies its request,
while the credential is issued only to the browser holding a separate HttpOnly claim secret and is
stored only as a digest. Revocation is server-side and checked on every principal resolution;
re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lite provides module
runtime primitives and deliberately no human authentication, permission enforcement, or managed
device identity, so pairing is a full-Ekklesia concern only.
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only
+37
View File
@@ -826,6 +826,43 @@ still the right choice when the response must be computed, transformed, or autho
There are no signed **write** URLs: uploads go through a module route, where the module can validate
what it is accepting.
## Display device pairing and management
Host-owned, and reachable by no module
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). Two sibling
prefixes: `/api/v1/device-pairing` and `/api/v1/devices`.
**The screen's side** (no identity required; authority is the HttpOnly claim cookie the first call
sets, never the code):
| route | purpose |
| --- | --- |
| `POST /api/v1/device-pairing/requests` | Body `{moduleId}`. Returns `{code, expiresAt, pollIntervalMs}` and sets `ekklesia_device_pairing`. The permission is read from that module's validated display declaration — a request cannot name one. |
| `POST /api/v1/device-pairing/claims` | Polls. Returns `{status}`; on `paired` it sets `ekklesia_device_session` and clears the pairing cookie. |
**The administrator's side** (requires the restricted `core.devices.manage` and an exact trusted
`Origin`):
| route | purpose |
| --- | --- |
| `POST /api/v1/device-pairing/lookups` | Body `{code}`. Shows the module and permission a screen is asking for, before approving. |
| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName}`. The administrator names the screen; the screen never names itself. |
| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. |
| `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. |
The pairing code travels in request **bodies**, never a path or query, so it does not land in access
logs, browser history, or a `Referer`. Expired, consumed, mistyped, and never-existed codes all
answer the same way.
`/api/v1/principal` deliberately reports a device as `anonymous`: it answers a question about
*people*, and the Dashboard depends on that. A display reads its viewer from the display descriptor
instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "device", deviceId}`.
**Cookies.** `ekklesia_device_session` is `HttpOnly`, `SameSite=Strict`, host-only, `Path=/`, and
`Secure` when the identity base URL is https. Deployment note: because it is host-only and
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
own `/display/<module-id>` route is.
## Module HTTP routes
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
+24
View File
@@ -275,6 +275,30 @@ unregistered target permission instead receives the identical sanitized
`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never
reveals whether the caller was denied or the permission was simply never registered.
## Device permissions
A paired display device holds explicit permissions of its own, in `ekklesia_core_device_permission`
rather than in `ekklesia_core_grant`
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). That table's
`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.
**It is a separate table, not a separate decision.** `createAuthorizationEvaluator` consults exactly
one authority per call, chosen by principal kind, and a device reaches `allowed` only by holding the
exact permission being checked:
- `public` — allowed, as for any principal.
- `authenticated` — **denied**, always. A device is not a person.
- `restricted` — allowed only with an active, unrevoked permission for that exact device.
A device is never a platform owner, and the owner and user-grant ports are not consulted for one at
all. A deployment that composed no device support denies every device. Revoking a device revokes its
permissions with it, and the permission lookup joins the device row so a revoked device has no
permissions even if a row were left behind.
Approving a pairing grants exactly the one display permission the host derived from the module's
validated display declaration — never a bundle, never a permission the browser named.
## Platform-owner bootstrap
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
+14
View File
@@ -265,6 +265,20 @@ registry) is covered separately in [Authorization](authorization.md). Its bootst
an existing identity; it does not create one. Creating the *first* identity on a fresh installation
is what `pnpm identity:create-initial-owner` is for — see below.
## Three principal classes
Ekklesia recognizes **anonymous**, **authenticated** (a person), and **device** (a paired display)
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)).
A device is not a user: it has no row in the identity tables, no email, no password, and no human
session. `isAuthenticatedPrincipal` returns `false` for it, which is what keeps every "somebody is
signed in" route closed to a screen on a wall. Its credential is a separate HttpOnly cookie
(`ekklesia_device_session`), resolved after Better Auth has been asked first — a human session always
wins, so a browser holding both remains a person.
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
to decide whether a person is signed in, and a device is not one.
## Creating the first platform owner
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
+38
View File
@@ -544,6 +544,44 @@ is **where it can be invoked**, not a weaker policy:
It deliberately does not solve creating *additional* users or owners. That is user administration,
which does not exist yet.
### A display device is a principal, not a person and not a shortcut
An unattended screen has its own identity, paired by an administrator and revocable by one
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). What makes that
safe is what it is *not* allowed to be:
- **Not a user.** No device row exists in the identity tables and no device has an email or a
password. A device satisfies the `authenticated` baseline **never**, so every route that settles
for "somebody is signed in" stays closed to a television.
- **Not a bypass.** Device authority is decided by the same `createAuthorizationEvaluator` as
everyone else's, with the same deny-by-default. There is no device branch that returns allowed
before consulting an explicit permission, and no device is ever a platform owner.
- **Not broadly granted.** Approval grants exactly the one display permission the host derived from
the module's own validated display declaration. A browser cannot ask for a permission of its
choosing, and a device paired for one display has no authority over another.
- **Not a Dashboard login.** `/api/v1/principal` reports a device as anonymous, and the shell tests
for `=== "authenticated"` rather than "not anonymous". A device cookie cannot open the Dashboard.
- **Not the pairing code.** The six-digit code is displayed on a wall and is treated as public. It
identifies a request to an administrator; it never yields a credential. Collecting the credential
additionally requires the HttpOnly claim cookie only the requesting browser holds, so reading a
code off a screen is worth nothing.
- **Not stored in a usable form.** The server keeps SHA-256 of the credential. The raw value exists
only in the `Set-Cookie` that issues it and in the device's own cookie jar; it is never logged, put
in a query string, returned in JSON, or written to `localStorage`. The cookie is `HttpOnly`, so the
module display code running in that very page cannot read it.
- **Not revocable only at the browser.** Revocation is a server-side column checked on every
principal resolution. A revoked screen keeps its cookie and stops working; re-pairing creates a
**new** identity, so an old credential can never be reactivated.
Approval, listing, and revocation require the restricted `core.devices.manage` permission and an
exact trusted `Origin`, exactly like every other Core mutation. A device cannot approve a pairing —
including its own.
**Bounded, not instantaneous.** A revoked device fails its next request and its next realtime
connection immediately, but an already-open SSE stream is not torn down at that moment: realtime's
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
a channel. That window is stated rather than described as instant.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
+24
View File
@@ -351,6 +351,30 @@ password arriving on standard input and never in `argv`, `--password` refused, a
`DATABASE_URL`, and an unmigrated database refused rather than silently migrated — with assertions
that neither stdout nor stderr contains the password or the connection string.
A further suite (`apps/api/tests/device-pairing.integration.ts`, same harness) proves display device
pairing end to end against real PostgreSQL and the real HTTP transport
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). None of it is
mocked: a screen with no identity is refused the descriptor but served the page it needs, asks to be
paired, and receives a code; a real platform owner — created by the Pass 1B bootstrap and signed in
through the real login transport — looks the code up and approves it by name; the screen collects its
credential from a real `Set-Cookie` and then opens the real display descriptor and a real realtime
stream; and the owner revokes it, after which both fail while the device still holds its cookie.
The suite also pins the properties that make the design safe rather than merely working: that the
raw credential appears nowhere in the database or in any response body, that the permission is taken
from the display declaration and not from a request that tried to name `core.devices.manage`, that
someone who knows only the code (an onlooker reading a television) is issued nothing, that anonymous
callers, a paired device, and a cross-origin administrator are all refused approval, that one code
cannot be approved twice or claimed into two credentials under a race, that re-pairing produces a
fresh identity while the old credential stays dead, and that a browser holding both a human session
and a device cookie remains a person.
Device authority itself is proven without a database in `core/tests/authorization-device.test.ts`,
which is mostly a list of things a device must *not* acquire: authority from being a principal, from
an `authenticated` baseline, from platform ownership, or from a user grant. Its ports are wired to
answer "yes" to every human question, so any accidental fall-through into a human path would flip
those assertions.
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind: