Pair displays at the platform's own /display/setup, with or without modules
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

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]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
@@ -3,6 +3,7 @@
- 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),
@@ -1,9 +1,10 @@
# 0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page
# 0065: Displays pair at the platform's own `/display/setup`; phone forms never zoom; the drawer locks the page
- Status: Accepted
- Date: 2026-10-16
- Supersedes, in part: [0036](0036-managed-device-identity-and-pairing.md) (every pairing request
names a module display)
- Extends: [0033](0033-display-kiosk-host-contract.md),
[0036](0036-managed-device-identity-and-pairing.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0045](0045-split-dashboard-and-api-origins.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
@@ -15,8 +16,7 @@ A second hand review on a real iPhone, after ADR 0064, found three defects:
- **Settings → Displays did not say how to set up a screen.** Add display took a pairing code, but
nothing on the page told an administrator how to get a physical screen to show one. The only setup
instructions were in Signage's own Displays panel. A platform-owned display has to be set up from
the platform, including on an installation without Signage.
instructions were in Signage's own Displays panel.
- **iOS Safari zoomed into the Add display form.** Focusing the pairing code zoomed the page into the
dialog, and the page stayed zoomed after the dialog closed. The cause is the shared `.ek-control`
font size: `typography.size-sm`, 0.875rem, which is 14px. iOS Safari zooms the page into any
@@ -24,58 +24,122 @@ A second hand review on a real iPhone, after ADR 0064, found three defects:
- **The page behind the phone drawer still scrolled,** and pulling down behind it triggered the
browser's pull-to-refresh.
The display host is already generic. `/display/<module-id>` is the platform's own page (ADR 0033): it
shows a pairing code, waits for approval, then hosts that module's display artifact. A pairing request
names the module because approval grants exactly that module's display permission (ADR 0036).
However, nothing told an administrator which of these pages exist. ADR 0033's display routes noted:
"There is deliberately no display inventory in this pass. A management UI may need one later." That
management UI now exists.
The deeper problem behind the first defect was architectural. Displays are platform-owned (ROADMAP
§3.3, §5.4), but a screen could only start pairing on a module's display page, `/display/<module-id>`
(ADR 0033). Every pairing request named a module, the database required it (`module_id` and
`permission_id` were `NOT NULL`), and approval granted exactly that module's display permission (ADR
0036). So an installation without Signage, or any other module with a display, could not set up a
display at all, and the platform's display identity was something only a module could bring into
being.
The identity itself never needed a module. A device is its own principal, and its permissions live
in their own table: a device with none is already representable.
## Decision
### `GET /api/v1/devices/setup`: where a new screen starts
### A screen starts at the platform's own `/display/setup`
The API answers, for an administrator holding `core.devices.manage`, the platform display page of
every running module that shows content on screens:
`/display/setup` is the platform's own display page. It is served whenever the deployment has device
support, whatever is installed, including no module at all. It is the same inert, module-agnostic
page every display is served. `setup` joins Core's reserved module ids in a third group,
`RESERVED_DISPLAY_HOST_NAMES`, so no module's display can take the address.
On it, the display shell asks the platform who this screen is (`GET /api/v1/device-pairing/self`,
below):
- **Not paired, or revoked:** it shows a pairing code. It asks to be paired with no module:
`POST /api/v1/device-pairing/requests` with `{}`.
- **Paired and set to show a module:** it opens that module's display page, `location.replace`, which
takes over exactly as if the screen had opened it directly.
- **Paired with nothing set yet:** it shows "This screen is paired" and its name, and waits.
While waiting, it asks again every 30 seconds. That keeps its credential alive (ADR 0056: credentials
last while in use), notices revocation, and opens a module as soon as the display holds one.
### One display identity, with or without a module
There is no second identity system. A screen paired at `/display/setup` is an ordinary platform
device, in the same tables as any other, with the same credential, cookies, revocation, last-seen,
and listing. It simply holds no module display permission until it is given one.
Migration `core:0009_platform_display_pairing` lets a pairing request name no module. It relaxes
`module_id` and `permission_id` to nullable, and adds a check that they are set together or not at
all. It is additive, destroys nothing, and every existing request satisfies it unchanged. Like every
migration, an operator applies it explicitly.
- **Requests.** `{}` creates a platform request. `{ "moduleId": … }` is unchanged: it resolves the
module from the running display registry and derives the permission from its declaration. Any other
key is ignored, as it always was, so a browser can never name a permission.
- **Lookup and approval** answer `"moduleId": null, "permissionId": null` for a platform request.
- **Choosing a module at approval.** Approval accepts an optional `moduleId`: the installed display
module the administrator chose for a platform display to show. It must be a running display. The
permission granted is that module's declared display permission, never one the request names. The
choice is written in the same conditional update as the approval, and only where the request names
no module or already names that one, so a module display's own request can never be redirected to
another module's permission. Claiming grants the request's permission, or none.
### `GET /api/v1/devices/display-modules`: what a display can show
This PR first added `GET /api/v1/devices/setup`, which listed each display module's page as a place
to start pairing. With `/display/setup`, setup needs no list, so that endpoint is removed. What remains
is a separate, real need: knowing which modules a display can be set to show. That need is met by
`GET /api/v1/devices/display-modules`:
```json
{ "displays": [{ "moduleId": "signage", "name": "Signage", "url": "/display/signage" }] }
{ "modules": [{ "moduleId": "signage", "name": "Signage", "permissionId": "signage.display" }] }
```
It reads the same eligible, running display registry as the display routes. It answers nothing to
anyone else: it is never anonymous, so an unpaired screen still cannot enumerate what is installed.
It is `no-store`. The URL is root-relative, and the browser resolves it against the API origin,
because the display host lives there in both topologies (ADR 0045).
It requires `core.devices.manage`, like the device list, is `no-store`, and is never anonymous, so it
is not a way to enumerate what is installed. Settings uses it for the **Show on this screen** choice,
and to say what each paired display shows ("Shows Signage", "Not showing a module yet"). An empty list
is a real answer.
**No module-independent display page is added.** A page that let a screen pair before choosing a
module would need a device to exist without a module permission. That is the ROADMAP §5.4 global
display model, and it needs its own decision about assigning devices to modules. Until then, every
screen pairs through a module's display page, and this endpoint is how the platform names those
pages.
### `GET /api/v1/device-pairing/self`: a display asking about itself
### Setting up a screen is one flow, in one place
This endpoint answers only a device principal. It returns its name, and the module display pages it
holds the permission for:
```json
{ "name": "Foyer TV", "displays": [{ "moduleId": "signage", "url": "/display/signage" }] }
```
A person (even an administrator who opened the page on their own laptop), an unpaired screen, and a
revoked device are refused. It renews the device's cookie exactly as a module display's descriptor
does, and is `no-store`.
### Settings → Displays: one flow, in one place, whatever is installed
Settings → Displays opens with a **Set up a new screen** panel, above the paired displays. Its
contents are, in order:
1. one sentence: *Open this address on the new screen, then add it with the code it shows*;
2. the absolute display page, with **Copy link**;
2. the platform's setup address, `https://<api-origin>/display/setup`, with **Copy setup link**;
3. **Add display**, right beneath it.
Each step is directly under the one before, so nobody has to go back up the page. **Add display**
lives here and only here, not in the page header.
Each step is directly under the one before. **Add display** lives here and only here, and it is always
offered, whatever modules are installed. The step-by-step version is folded away in a native
`<details>`.
The step-by-step version is folded away in a native `<details>` (*Step-by-step instructions*), for
whoever wants it, instead of a block of text everyone has to read past. With more than one module
that uses displays, each address is labelled with its module.
**Add display** looks the code up, then:
When no installed module uses displays, the panel says so and that one installed from Discover will
appear there. It offers no **Add display**, which could only fail. Nothing names Signage. If the
setup address cannot be read, the panel says so and still offers **Add display**, since a screen may
already be showing its code. The paired list keeps working either way.
- **A screen from `/display/setup`:** "A new screen is waiting to be paired". The administrator
names it, and, if any installed module shows content on screens, may choose **Show on this screen**:
*Nothing yet* (the default) or a module. With no such module, the dialog says the display is paired
now and waits.
- **A screen from a module's own display page:** "A display is asking to show Signage", as before,
with no choice.
**Copy link** reaches the clipboard through the clicked button's own window, never a browser global
(ADR 0058). If the browser cannot copy, the button says so and the address stays selectable.
If the module list cannot be read, the choice is simply not offered and the display pairs showing
nothing yet. **Copy setup link** reaches the clipboard through the clicked button's own window, never
a browser global (ADR 0058).
### Module display routes are unchanged
`/display/<module-id>` keeps working exactly as it did: a screen that opens it directly still pairs
with that module's permission, and released Signage 0.1.5 is unaffected. It is simply no longer the
only way to bring a display into being. A screen paired at `/display/setup` and set to show Signage
holds `signage.display`, exactly what a screen paired at `/display/signage` holds, so Signage 0.1.5
sees and uses it the same way.
### Text controls are at least 16px on phones and touch screens
@@ -121,15 +185,22 @@ unchanged.
## Alternatives
- **Copy Signage's setup address into Settings.** Rejected: it names one module, and it is wrong on an
installation without it.
- **Keep pairing only on module display pages, and list them in Settings.** This was this PR's first
version. Rejected: it made a platform-owned display depend on a module existing, and an
installation with no display module could not set one up at all.
- **A public `/display` page listing the modules that use displays.** Rejected: it would tell anyone
on the network what is installed, which ADR 0036 deliberately prevents.
- **Pair first, choose the module later.** That is the right long-term model (ROADMAP §5.4). It is
deferred because it changes how a device's permission is granted, and needs its own decision.
- **Use the Dashboard navigation's `display.url`.** Rejected: the navigation lists only modules with
a Dashboard UI, and filters by module permissions. A display-only module, or one the administrator
cannot open, would be missing.
on the network what is installed, which ADR 0036 deliberately prevents. `/display/setup` lists
nothing; what a screen may show reaches only that screen, after it is paired.
- **A sentinel module (for example a `platform` module id) instead of nullable columns.** Rejected: a
value meaning "no module" in a module column is a second, hidden meaning that every reader must know
about.
- **Leaving assignment for later, so a display from `/display/setup` shows nothing until a later
PR.** Rejected by the maintainer: with Signage installed, the platform's own setup flow would lead to
a screen that could not show Signage, while Signage's page still worked. Choosing at approval
closes the loop with the permission table that already exists.
- **Changing or reassigning a paired display's module after pairing.** Deferred: it needs revocation
and arbitration rules between modules (DESIGN.md §11.6). Today a display's module is chosen once, at
approval, or by the module page it was opened on.
- **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and
hides the cause instead of fixing it.
- **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design.
@@ -138,9 +209,17 @@ unchanged.
## Consequences
- An administrator can set up a screen from Settings → Displays alone. Released Signage 0.1.5 keeps
its own instructions, which point to the same platform page, until its follow-up issue
(`Zayin/Signage#24`) is resolved.
- Any installation can set up and pair a display at `/display/setup`, with or without a module that
uses displays. A display is a platform resource; a module shows content on a display it is set to
show, rather than creating one.
- Migration `core:0009_platform_display_pairing` must be applied before this version runs. It is
additive and non-destructive.
- Every installation now serves the display host, so a missing display host build fails startup, as a
missing Dashboard build already did.
- `setup` can no longer be a module id. No known module uses it.
- Released Signage 0.1.5 keeps working unchanged. Its own setup text still sends administrators to
"Display devices in the Dashboard sidebar" (`Zayin/Signage#24`). Its own `/display/signage` setup
address still works; Settings now sends administrators to `/display/setup`.
- Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs
included.
- A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's
+2 -2
View File
@@ -44,7 +44,7 @@ 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 (session lifetime superseded by 0056)
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056; platform pairing without a module by 0065)
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
@@ -73,4 +73,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
- [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted
- [0064: Discover is its own page, the sidebar is navigation only, Settings opens on an overview, and phones get a sticky header](0064-stage-1-shell-review-corrections.md) — Accepted
- [0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page](0065-display-setup-phone-forms-and-drawer-scroll-lock.md) — Accepted
- [0065: Displays pair at the platform's own `/display/setup`; phone forms never zoom; the drawer locks the page](0065-display-setup-phone-forms-and-drawer-scroll-lock.md) — Accepted
+14 -6
View File
@@ -980,25 +980,33 @@ 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`.
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md),
[ADR 0065](../architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md)). Two
sibling prefixes: `/api/v1/device-pairing` and `/api/v1/devices`.
A new screen starts at the platform's own display page, `/display/setup`, which every installation
serves, whatever modules are installed. It pairs a **platform display** with no module; the
administrator may choose, when approving it, which installed display module it shows. A screen that
opens a module's display page, `/display/<module-id>`, still pairs with that module directly.
**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/requests` | Body `{}` from `/display/setup` (a platform display, no module), or `{moduleId}` from a module's display page. Returns `{code, expiresAt, pollIntervalMs}` and sets `ekklesia_device_pairing`. A module's permission is read from its validated display declaration — a request can never name one, and any other key is ignored. |
| `POST /api/v1/device-pairing/claims` | Polls. Returns `{status}`; on `paired` it sets `ekklesia_device_session` and clears the pairing cookie. |
| `GET /api/v1/device-pairing/self` | A **paired device** only: `{name, displays: [{moduleId, url}]}`, the module display pages it may show. Renews the device cookie. Anyone else — a person, an unpaired or revoked screen — is `403`. |
**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. |
| `POST /api/v1/device-pairing/lookups` | Body `{code}`. Shows the module and permission a screen is asking for — both `null` for a platform display — before approving. |
| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName, moduleId?}`. The administrator names the screen; the screen never names itself. `moduleId`, for a platform display, is the running display module it shows; its declared display permission is granted. A module display's own request cannot be given a different module. |
| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. |
| `GET /api/v1/devices/display-modules` | `{modules: [{moduleId, name, permissionId}]}`: the running modules a display can be set to show. |
| `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
@@ -1012,7 +1020,7 @@ instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "de
**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.
own `/display/setup` and `/display/<module-id>` routes are.
## Outbound email
+26 -6
View File
@@ -488,11 +488,27 @@ The second review's corrections
([ADR 0065](../architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md)) are
proven here:
- `apps/api/tests/device-pairing.integration.ts` covers `GET /api/v1/devices/setup`. It names each
display page, is `no-store`, and refuses an unpaired or paired screen.
- `surfaces/shell/tests/displays-settings.test.tsx` covers the setup steps on the API origin: Copy
link with and without a clipboard, several modules, none installed, a failed read, and a refused
non-root-relative page.
- `apps/api/tests/device-pairing.integration.ts`, on real PostgreSQL:
- with **zero** modules installed, a screen at `/display/setup` pairs as a platform display with
no permission;
- the display can describe itself, an administrator cannot impersonate it, and revocation works;
- an administrator can choose the module a platform display shows at approval;
- a module display's own request cannot be redirected, and unknown keys and modules are refused.
- `apps/api/tests/module-display-routes.test.ts` serves `/display/setup` and the display host with
zero display modules, and only with device support.
- `core/tests/device-pairing-schema.test.ts` pins migration `0009` to its three additive statements.
- `surfaces/shell/tests/display-setup-app.test.tsx` covers the setup page:
- it pairs with no module and waits, named;
- it opens the module it is set to show;
- it opens a module as soon as one is set;
- it returns to a code when revoked;
- it never pairs on a server failure.
- `surfaces/shell/tests/displays-settings.test.tsx` covers:
- the platform setup address, and **Copy setup link**;
- **Add display** with zero display modules;
- the **Show on this screen** choice;
- a module display's request showing its fixed module;
- pairing still working when the module list cannot be read.
- `surfaces/shell/tests/scroll-lock.test.ts` covers:
- the root locked and its previous styles restored;
- drags outside the drawer cancelled, and drags inside it allowed except past its ends;
@@ -503,7 +519,11 @@ proven here:
- `surfaces/shell/tests/viewport.test.ts` keeps zoom available on both shell pages.
- `packages/ui/tests/tokens.test.ts` holds text controls at 16px or more on phones and touch screens.
- `packages/ui/tests/interaction.test.tsx` checks that the focus trap returns focus without scrolling.
- The deploy smoke checks, in real Chromium:
- The deploy smoke checks:
- `/display/setup` and a module-less pairing request through the proxy, in both topologies, so in
the same-origin run with no module at all;
- in real Chromium, a screen in its own browser context at `/display/setup` showing a code, being
added through Settings, and then opening its chosen module, or waiting, named;
- the setup steps on the API origin;
- the page locked behind the open drawer and released after;
- the pairing field at 16px or more on a phone.