ea28f8aa2e77647e34da83da85f6c5a55003dd53
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80189c8ae2 |
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]> |
||
|
|
a8c7c50b4d | feat: add browser auth shell | ||
|
|
2e0fe059c3 | feat: add display kiosk host | ||
|
|
bd34c9c714 |
feat: add Dashboard navigation as presentation configuration
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
616c1d4834 |
feat: mount discovered module UI in a minimal Ekklesia browser host
Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]> |