Files
Ekklesia/surfaces/shell/src/host-protocol.ts
T
ekklesiaandClaude Opus 5 80189c8ae2
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
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]>
2026-08-23 23:41:03 -05:00

774 lines
29 KiB
TypeScript

import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
/**
* How this shell reaches the Ekklesia API.
*
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`)
* rather than something read from a global. The browser entry point supplies it — from a build-time
* value when the shell and the API are served from different origins in development, and from
* `window.location.origin` otherwise — so every function below is a pure function of its arguments
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global.
*/
export interface EkklesiaHostConfig {
readonly apiBaseUrl: string;
/** Injected so tests can drive the real protocol against a real in-process app without a socket.
* Production composition leaves this unset and the platform `fetch` is used. */
readonly fetch?: typeof fetch;
/** Test/composition seam for the browser's native live-event client. Production leaves this
* unset and the platform `EventSource` constructor is used. This never enters module UI. */
readonly eventSourceFactory?: HostEventSourceFactory;
}
export interface HostEventSourceOptions {
readonly withCredentials: true;
}
export type HostEventSourceFactory = (url: string, options: HostEventSourceOptions) => EventSource;
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
* URL — the server produces both and hands them over in the navigation view — so these two are the
* only URLs it knows, and they describe the platform itself rather than any module.
*
* `/api/v1/module-ui` is deliberately absent: that host-neutral inventory (ADR 0026) remains the
* endpoint a standalone host would consume, but Ekklesia's own shell needs the arrangement too, so
* it reads the navigation view instead. Keeping an unused client for it here would be speculative
* code for a consumer that does not exist. */
const PRINCIPAL_PATH = "/api/v1/principal";
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const MODULE_ID_MAX_LENGTH = 64;
const SEMANTIC_VERSION_PATTERN =
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u;
export type HostProtocolErrorCode =
/** The caller is not signed in. */
| "HOST_UNAUTHENTICATED"
/** The caller is signed in but may not read the Dashboard resource. */
| "HOST_ACCESS_DENIED"
/** The API answered, but not with something this shell understands. */
| "HOST_RESPONSE_INVALID"
/** The API could not be reached, or failed. */
| "HOST_REQUEST_FAILED"
/** A module UI asked for something outside its own API root. */
| "MODULE_API_PATH_INVALID"
/** A module UI asked for a live channel outside the public local-channel grammar. */
| "MODULE_REALTIME_CHANNEL_INVALID"
/** The browser was not opened at one exact, valid display route. */
| "DISPLAY_ROUTE_INVALID"
/** The requested module does not currently publish a display. */
| "DISPLAY_NOT_AVAILABLE"
/** The signed-in principal may not mount the requested display. */
| "DISPLAY_ACCESS_DENIED";
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_ACCESS_DENIED: "You do not have access to the Ekklesia Dashboard.",
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.",
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.",
DISPLAY_ACCESS_DENIED: "You do not have access to this display.",
DISPLAY_NOT_AVAILABLE: "This display is not available.",
DISPLAY_ROUTE_INVALID: "This is not a valid display address.",
});
/**
* Carries a stable code and a fixed, user-safe message — never a URL, a response body, a server
* path, or a caught cause — matching every other Ekklesia error boundary. The originating error is
* attached as `cause` for browser-console diagnostics and is never rendered.
*/
export class HostProtocolError extends Error {
readonly code: HostProtocolErrorCode;
constructor(code: HostProtocolErrorCode, options?: { readonly cause?: unknown }) {
super(errorMessages[code], options);
this.name = "HostProtocolError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* One module UI this host may mount, exactly as the server described it.
*
* This mirrors the server's `HostModuleUiDescriptor` (`apps/api/src/module-ui-inventory.ts`) rather
* than importing it: a surface may not depend on an app, and the type on either side of an HTTP
* boundary should be validated on arrival rather than assumed. `parseHostModuleUiInventory` below is
* that validation, and the end-to-end test feeds a real server response through it so the two
* declarations are proven to agree instead of merely looking similar.
*/
export interface HostModuleUiDescriptor {
readonly id: string;
readonly version: string;
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
* unchanged; it never constructs, parses, or pattern-matches this URL. */
readonly uiUrl: string;
/** Server-produced root of this module's own HTTP API. */
readonly apiBaseUrl: string;
/** Server-produced root for this module's own live channels. */
readonly realtimeBaseUrl: string;
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
readonly permissions: readonly string[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function hasExactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
if (!isRecord(value)) {
return false;
}
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isModuleId(value: string): boolean {
return value.length <= MODULE_ID_MAX_LENGTH && MODULE_ID_PATTERN.test(value);
}
function isSemanticVersion(value: string): boolean {
return SEMANTIC_VERSION_PATTERN.test(value);
}
function nestedString(value: unknown, key: string): string | undefined {
return isRecord(value) ? nonEmptyString(value[key]) : undefined;
}
/**
* Every URL in a descriptor must be a **root-relative** path, exactly as the server produces it.
*
* Enforced rather than assumed. The shell resolves each against the configured API origin, so an
* absolute or protocol-relative value arriving here would silently redirect a module's script import
* or its API calls to another origin. This does not defend against a compromised server — one could
* serve anything at the real URL — but it does pin the wire contract, so a proxy rewrite, a
* misconfiguration, or a future server-side bug fails loudly here instead of quietly changing where
* browser code is fetched from.
*/
function rootRelativePath(value: unknown, key: string): string | undefined {
const candidate = nestedString(value, key);
if (
candidate === undefined ||
!candidate.startsWith("/") ||
candidate.startsWith("//") ||
candidate.includes("\\") ||
candidate.includes("?") ||
candidate.includes("#")
) {
return undefined;
}
const segments = candidate.slice(1).split("/");
if (segments.some((segment) => segment.length === 0)) {
return undefined;
}
try {
for (const segment of segments) {
const decoded = decodeURIComponent(segment);
if (
decoded === "." ||
decoded === ".." ||
decoded.includes("/") ||
decoded.includes("\\") ||
decoded.includes("\0")
) {
return undefined;
}
}
} catch {
return undefined;
}
return candidate;
}
/**
* Strict, total validation of the inventory document. Anything unexpected — a missing field, a
* non-array `modules`, a permission entry that is not a string — fails the whole parse rather than
* yielding a half-built descriptor, because a descriptor with a missing URL would surface much later
* as an inexplicable mount failure.
*/
function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
const id = nestedString(entry, "id");
const version = nestedString(entry, "version");
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "baseUrl") : undefined;
const permissions = isRecord(entry) ? entry.permissions : undefined;
if (
id === undefined ||
version === undefined ||
uiUrl === undefined ||
apiBaseUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string")
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
});
}
/**
* The display host's one-module response.
*
* The wire names the artifact `display`, not `ui`, so a server cannot accidentally hand the
* Dashboard artifact to the full-viewport host and still pass validation. Once validated, the
* result deliberately narrows to the same generic mount descriptor every host adapter already
* consumes: the generic loader neither knows nor needs to know which outer shell selected it.
*/
export interface HostModuleDisplayResponse {
readonly module: HostModuleUiDescriptor;
/** Who the host resolved as the viewer. Read from the response that already authorized this
* display rather than from `/api/v1/principal`, which answers only about people. */
readonly viewer: ModuleUiPrincipal;
}
/** Strictly validates the host's statement of who is viewing. Only the two principal kinds a
* `restricted` display permission can admit are accepted — an anonymous viewer here would mean the
* server authorized a display for nobody, which is a protocol violation rather than a state to
* render. */
function parseDisplayViewer(payload: unknown): ModuleUiPrincipal {
if (hasExactKeys(payload, ["kind", "userId"]) && payload.kind === "authenticated") {
const userId = nonEmptyString(payload.userId);
if (userId !== undefined) {
return Object.freeze({ kind: "authenticated" as const, userId });
}
}
if (hasExactKeys(payload, ["deviceId", "kind"]) && payload.kind === "device") {
const deviceId = nonEmptyString(payload.deviceId);
if (deviceId !== undefined) {
return Object.freeze({ deviceId, kind: "device" as const });
}
}
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/** Strictly validates the display descriptor response, including every nested URL container. */
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
if (!hasExactKeys(payload, ["module", "viewer"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const viewer = parseDisplayViewer(payload.viewer);
const entry = payload.module;
if (
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
!hasExactKeys(entry.api, ["baseUrl"]) ||
!hasExactKeys(entry.display, ["url"]) ||
!hasExactKeys(entry.realtime, ["baseUrl"])
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const id = nonEmptyString(entry.id);
const version = nonEmptyString(entry.version);
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
const uiUrl = rootRelativePath(entry.display, "url");
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
const permissions = entry.permissions;
if (
id === undefined ||
!isModuleId(id) ||
version === undefined ||
!isSemanticVersion(version) ||
apiBaseUrl === undefined ||
uiUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
new Set(permissions).size !== permissions.length
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
module: Object.freeze({
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
}),
viewer,
});
}
/** A same-origin, credential-carrying platform mutation. Mirrors `auth-protocol.ts`'s `postJson`:
* `redirect: "error"` so a misconfigured deployment cannot silently follow a redirect with the
* device's cookies attached, and no thrown value ever carries the request body. */
async function requestPlatformMutation(
config: EkklesiaHostConfig,
rootRelativePath: string,
body: unknown,
signal?: AbortSignal,
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
body: JSON.stringify(body),
cache: "no-store",
credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" },
method: "POST",
redirect: "error",
...(signal ? { signal } : {}),
});
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (response.status === 401 || response.status === 403) {
throw new HostProtocolError("HOST_UNAUTHENTICATED");
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
try {
return (await response.json()) as unknown;
} catch {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
}
/**
* Asks the host to start pairing this screen, and returns the code to put on it.
*
* The response deliberately carries no secret. The value that proves this browser is the one that
* asked travels in an HttpOnly cookie the host sets, so nothing here — and nothing anyone reads off
* the screen — can be used to collect the device's credential (ADR 0036).
*/
export async function requestDevicePairing(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<{ readonly code: string; readonly pollIntervalMs: number }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/requests",
{ moduleId },
signal,
);
if (!hasExactKeys(payload, ["code", "expiresAt", "pollIntervalMs"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const code = nonEmptyString(payload.code);
const pollIntervalMs = payload.pollIntervalMs;
if (
code === undefined ||
typeof pollIntervalMs !== "number" ||
!Number.isFinite(pollIntervalMs)
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ code, pollIntervalMs: Math.max(1000, Math.trunc(pollIntervalMs)) });
}
/** Asks whether this screen has been approved yet. When it has, the host issues the device session
* in a `Set-Cookie` the browser stores and this code never sees. */
export async function pollDevicePairing(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<string> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/claims",
{},
signal,
);
if (!hasExactKeys(payload, ["status"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const status = nonEmptyString(payload.status);
if (status === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return status;
}
/** One paired display, as the administrator's panel shows it. Deliberately no credential, no hash,
* and no pairing history — none of that is anything an operator can act on. */
export interface HostDeviceSummary {
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
readonly revoked: boolean;
}
export async function lookupDevicePairing(
config: EkklesiaHostConfig,
code: string,
signal?: AbortSignal,
): Promise<{ readonly moduleId: string; readonly permissionId: string }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/lookups",
{ code },
signal,
);
if (!hasExactKeys(payload, ["expiresAt", "moduleId", "permissionId", "requestedAt"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const moduleId = nonEmptyString(payload.moduleId);
const permissionId = nonEmptyString(payload.permissionId);
if (moduleId === undefined || permissionId === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, permissionId });
}
export async function approveDevicePairing(
config: EkklesiaHostConfig,
code: string,
deviceName: string,
signal?: AbortSignal,
): Promise<void> {
await requestPlatformMutation(
config,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
signal,
);
}
export async function listHostDevices(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostDeviceSummary[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (typeof payload !== "object" || payload === null || !("devices" in payload)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const entries = (payload as { devices: unknown }).devices;
if (!Array.isArray(entries)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
entries.map((entry) => {
const id = nonEmptyString((entry as { id?: unknown }).id);
const name = nonEmptyString((entry as { name?: unknown }).name);
const permissions = (entry as { permissions?: unknown }).permissions;
if (id === undefined || name === undefined || !Array.isArray(permissions)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
id,
name,
permissions: Object.freeze(permissions.map((permission) => String(permission))),
revoked: (entry as { revoked?: unknown }).revoked === true,
});
}),
);
}
export async function revokeHostDevice(
config: EkklesiaHostConfig,
deviceId: string,
signal?: AbortSignal,
): Promise<void> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(
resolveApiUrl(config, `/api/v1/devices/${encodeURIComponent(deviceId)}/revoke`),
{
body: "{}",
cache: "no-store",
credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" },
method: "POST",
redirect: "error",
...(signal ? { signal } : {}),
},
);
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
}
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
export function parseDisplayRoute(pathname: string): string {
const match = /^\/display\/([^/]+)$/u.exec(pathname);
if (!match?.[1]) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
let moduleId: string;
try {
moduleId = decodeURIComponent(match[1]);
} catch {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
if (!isModuleId(moduleId)) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
return moduleId;
}
/** The only platform path a display browser builds, from a module id already validated above. */
export function moduleDisplayDescriptorPath(moduleId: string): string {
if (!isModuleId(moduleId)) {
throw new HostProtocolError("DISPLAY_ROUTE_INVALID");
}
return `${MODULE_DISPLAYS_PATH}/${encodeURIComponent(moduleId)}`;
}
/**
* One module as the Dashboard presents it.
*
* The mount descriptor is nested exactly as the server sends it, not flattened into this object.
* That keeps the two concerns visibly separate in the browser as well as on the wire: `descriptor`
* is the host-neutral "what and how to mount", and `name` is Ekklesia's presentation of it. Nothing
* about a group is stored on the module — a module does not know, and must not know, where it has
* been placed.
*/
export interface DashboardNavigationEntry {
readonly name: string;
readonly descriptor: HostModuleUiDescriptor;
}
export interface DashboardNavigationGroup {
/** Opaque and stable; the shell uses it as a React key and never displays it. */
readonly id: string;
readonly label: string;
readonly modules: readonly DashboardNavigationEntry[];
}
/**
* The whole navigation the shell renders, already resolved and ordered by the server.
*
* The browser performs no grouping, no sorting, and no joining of its own: it does not read
* manifests, does not know which module belongs where, and could not compute this view if it wanted
* to. Ordering arrives as array order, so a change to the server's ordering rules needs no shell
* change at all.
*/
export interface DashboardNavigation {
readonly groups: readonly DashboardNavigationGroup[];
readonly ungrouped: readonly DashboardNavigationEntry[];
}
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
const name = nestedString(entry, "name");
if (name === undefined || !isRecord(entry)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
}
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
* the whole document rather than rendering a half-built navigation. */
export function parseDashboardNavigation(payload: unknown): DashboardNavigation {
const groups = isRecord(payload) ? payload.groups : undefined;
const ungrouped = isRecord(payload) ? payload.ungrouped : undefined;
if (!Array.isArray(groups) || !Array.isArray(ungrouped)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
groups: Object.freeze(
groups.map((group): DashboardNavigationGroup => {
const id = nestedString(group, "id");
const label = nestedString(group, "label");
const modules = isRecord(group) ? group.modules : undefined;
if (id === undefined || label === undefined || !Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
id,
label,
modules: Object.freeze(modules.map((entry) => parseNavigationEntry(entry))),
});
}),
),
ungrouped: Object.freeze(ungrouped.map((entry) => parseNavigationEntry(entry))),
});
}
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
* the UI SDK defines. Nothing else the endpoint might ever return is carried into module UI. */
export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
if (!isRecord(payload)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
if (payload.kind === "anonymous") {
return { kind: "anonymous" };
}
const userId = nonEmptyString(payload.userId);
if (payload.kind === "authenticated" && userId !== undefined) {
return { kind: "authenticated", userId };
}
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/** Resolves a root-relative platform path against the configured API origin. The one place the
* shell turns a server-produced path into an absolute URL. */
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
return new URL(rootRelativePath, config.apiBaseUrl).href;
}
/**
* Every request this shell makes to Ekklesia carries credentials.
*
* Same-origin that is simply the session cookie. Split-origin — the development shape the platform's
* trusted-origin configuration already supports — it is what makes the cookie travel at all, and it
* works only because the API reflects an exact trusted origin with
* `Access-Control-Allow-Credentials: true` and never a wildcard. This does not weaken that policy;
* it is the client half of it.
*/
async function requestPlatformJson(
config: EkklesiaHostConfig,
rootRelativePath: string,
signal?: AbortSignal,
statusCodes: {
readonly forbidden?: HostProtocolErrorCode;
readonly notFound?: HostProtocolErrorCode;
} = {},
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
credentials: "include",
headers: { accept: "application/json" },
...(signal ? { signal } : {}),
});
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (response.status === 401) {
throw new HostProtocolError("HOST_UNAUTHENTICATED");
}
if (response.status === 403) {
throw new HostProtocolError(statusCodes.forbidden ?? "HOST_UNAUTHENTICATED");
}
if (response.status === 404 && statusCodes.notFound) {
throw new HostProtocolError(statusCodes.notFound);
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
try {
return (await response.json()) as unknown;
} catch (error) {
throw new HostProtocolError("HOST_RESPONSE_INVALID", { cause: error });
}
}
/** Who is viewing. Anonymous is a normal answer, not a failure: the shell renders a sign-in prompt
* rather than an error, and never asks for the inventory it would be refused. */
export async function loadHostPrincipal(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<ModuleUiPrincipal> {
return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal));
}
/** The resolved Dashboard navigation: the same mountable modules, arranged as this installation has
* configured them (ADR 0027). */
export async function loadDashboardNavigation(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<DashboardNavigation> {
return parseDashboardNavigation(
await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal, {
forbidden: "HOST_ACCESS_DENIED",
}),
);
}
/**
* Resolves the one display the outer host route named.
*
* The module id chooses only this host-owned descriptor resource. It never chooses a file: the
* response supplies the validated display artifact URL, API root, and realtime root, and the
* strict parser above rejects anything absolute, protocol-relative, or structurally ambiguous.
*/
export async function loadModuleDisplay(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<HostModuleDisplayResponse> {
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
forbidden: "DISPLAY_ACCESS_DENIED",
notFound: "DISPLAY_NOT_AVAILABLE",
});
const response = parseHostModuleDisplay(payload);
if (response.module.id !== moduleId) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return response;
}
export type DisplayHostBootstrap = {
readonly status: "ready";
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
};
/**
* Descriptor first, deliberately — the opposite of the Dashboard's principal-first bootstrap.
*
* The Dashboard asks who you are before asking what you may mount, because it is asking about a
* person and `/api/v1/principal` answers that plainly. A display cannot: a paired device is
* anonymous to that endpoint by design (ADR 0036), so a principal-first display would conclude that
* every device in the building was signed out. The descriptor is the actual authorization boundary
* for a display, and it reports the viewer it authorized, so one request settles both questions for
* a person and a device alike.
*
* A denied descriptor throws `DISPLAY_ACCESS_DENIED`, which is what the host turns into pairing.
*/
export async function bootstrapDisplayHost(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<DisplayHostBootstrap> {
const response = await loadModuleDisplay(config, moduleId, signal);
return Object.freeze({
descriptor: response.module,
principal: response.viewer,
status: "ready" as const,
});
}
export interface HostBootstrap {
readonly principal: ModuleUiPrincipal;
readonly navigation: DashboardNavigation;
}
const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({
groups: Object.freeze([]),
ungrouped: Object.freeze([]),
});
/**
* The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask
* what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to
* sign in and the inventory is never requested, so the shell does not depend on a `403` to discover
* something `/api/v1/principal` already said plainly.
*/
export async function bootstrapHost(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<HostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return { navigation: EMPTY_NAVIGATION, principal };
}
return { navigation: await loadDashboardNavigation(config, signal), principal };
}