Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1132 lines
43 KiB
TypeScript
1132 lines
43 KiB
TypeScript
import {
|
|
EKKLESIA_APPEARANCES,
|
|
EKKLESIA_MOTION_PREFERENCES,
|
|
type EkklesiaAppearance,
|
|
type EkklesiaMotionPreference,
|
|
} from "@ekklesia/design-tokens";
|
|
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
|
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
|
import {
|
|
type DashboardModulePage,
|
|
isDashboardModulePage,
|
|
isModuleIconName,
|
|
} from "./module-navigation.js";
|
|
|
|
/**
|
|
* 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 the API
|
|
* origin the serving API wrote into the Dashboard page (ADR 0045, {@link resolveApiOrigin}), and from
|
|
* `window.location.origin` for the display host, which is always served from the API's own origin —
|
|
* 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 DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
|
|
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
|
|
const ACCOUNT_PATH = "/api/v1/account";
|
|
const ACCOUNT_PREFERENCES_PATH = "/api/v1/account/preferences";
|
|
|
|
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 page for this module's display, when it publishes one. */
|
|
readonly displayPageUrl: string | undefined;
|
|
/** 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[];
|
|
/** Renderers other modules contributed to capabilities this one provides (ADR 0041). The shell
|
|
* hands identity to the module and resolves the URL itself; the module never sees this URL. */
|
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
|
}
|
|
|
|
/** One contributed renderer this module may mount, exactly as the server described it. Mirrors the
|
|
* server's `HostModuleContributionDescriptor` rather than importing it, for the same reason the
|
|
* module descriptor does. */
|
|
export interface HostModuleContributionDescriptor {
|
|
readonly id: string;
|
|
readonly version: string;
|
|
readonly capability: string;
|
|
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic
|
|
* contribution loader unchanged. */
|
|
readonly url: 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 displayPageUrl =
|
|
isRecord(entry) && entry.display !== null ? rootRelativePath(entry.display, "url") : undefined;
|
|
const permissions = isRecord(entry) ? entry.permissions : undefined;
|
|
if (
|
|
!hasExactKeys(entry, [
|
|
"api",
|
|
"contributions",
|
|
"display",
|
|
"id",
|
|
"permissions",
|
|
"realtime",
|
|
"ui",
|
|
"version",
|
|
]) ||
|
|
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
|
!hasExactKeys(entry.realtime, ["baseUrl"]) ||
|
|
!hasExactKeys(entry.ui, ["url"]) ||
|
|
(entry.display !== null && !hasExactKeys(entry.display, ["url"])) ||
|
|
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,
|
|
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
|
displayPageUrl,
|
|
id,
|
|
permissions: Object.freeze([...(permissions as string[])]),
|
|
realtimeBaseUrl,
|
|
uiUrl,
|
|
version,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Strict, total validation of the contribution list.
|
|
*
|
|
* Every entry must carry an exact key set, a well-formed identity, and a root-relative URL that
|
|
* passes the same containment checks every other server-produced URL does — a contribution URL is a
|
|
* thing the browser will `import()`, so it gets no weaker treatment than the module artifact URL.
|
|
* Anything unexpected fails the whole parse rather than yielding a half-built list, because a
|
|
* descriptor with a missing URL surfaces much later as an inexplicable mount failure.
|
|
*/
|
|
function parseContributions(value: unknown): readonly HostModuleContributionDescriptor[] {
|
|
if (!Array.isArray(value)) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
const descriptors = value.map((entry) => {
|
|
if (!hasExactKeys(entry, ["capability", "id", "url", "version"])) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
const id = nonEmptyString(entry.id);
|
|
const version = nonEmptyString(entry.version);
|
|
const capability = nonEmptyString(entry.capability);
|
|
const url = rootRelativePath({ url: entry.url }, "url");
|
|
if (
|
|
id === undefined ||
|
|
version === undefined ||
|
|
!isSemanticVersion(version) ||
|
|
capability === undefined ||
|
|
url === undefined
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze({ capability, id, url, version });
|
|
});
|
|
if (new Set(descriptors.map((descriptor) => descriptor.id)).size !== descriptors.length) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze(descriptors);
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
"contributions",
|
|
"display",
|
|
"id",
|
|
"page",
|
|
"permissions",
|
|
"realtime",
|
|
"version",
|
|
]) ||
|
|
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
|
!hasExactKeys(entry.display, ["url"]) ||
|
|
!hasExactKeys(entry.page, ["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 displayPageUrl = rootRelativePath(entry.page, "url");
|
|
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
|
|
const permissions = entry.permissions;
|
|
if (
|
|
id === undefined ||
|
|
!isModuleId(id) ||
|
|
version === undefined ||
|
|
!isSemanticVersion(version) ||
|
|
apiBaseUrl === undefined ||
|
|
uiUrl === undefined ||
|
|
displayPageUrl === 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,
|
|
contributions: parseContributions(entry.contributions),
|
|
displayPageUrl,
|
|
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,
|
|
method: "PATCH" | "POST" = "POST",
|
|
): 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,
|
|
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;
|
|
/** The Lucide name the module declared (manifest v9, ADR 0063), or `undefined` for the generic
|
|
* module icon. */
|
|
readonly icon: string | undefined;
|
|
/** Its primary pages, in its declared order; empty for a single-page module. */
|
|
readonly pages: readonly DashboardModulePage[];
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
/** Strict: the module's declared icon (`null` for none) and pages, each in the manifest's grammar,
|
|
* with no repeated page id or path. */
|
|
function parseModuleNavigation(value: unknown): {
|
|
readonly icon: string | undefined;
|
|
readonly pages: readonly DashboardModulePage[];
|
|
} {
|
|
if (
|
|
!hasExactKeys(value, ["icon", "pages"]) ||
|
|
(value.icon !== null && !isModuleIconName(value.icon)) ||
|
|
!Array.isArray(value.pages) ||
|
|
value.pages.length > 12 ||
|
|
!value.pages.every(isDashboardModulePage)
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
const pages = value.pages as readonly DashboardModulePage[];
|
|
if (
|
|
new Set(pages.map((page) => page.id)).size !== pages.length ||
|
|
new Set(pages.map((page) => page.path)).size !== pages.length
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze({
|
|
icon: value.icon === null ? undefined : (value.icon as string),
|
|
pages: Object.freeze(
|
|
pages.map((page) => Object.freeze({ id: page.id, label: page.label, path: page.path })),
|
|
),
|
|
});
|
|
}
|
|
|
|
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
|
|
const name = nestedString(entry, "name");
|
|
if (name === undefined || !hasExactKeys(entry, ["module", "name", "navigation"])) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
const navigation = parseModuleNavigation(entry.navigation);
|
|
return Object.freeze({
|
|
descriptor: parseDescriptor(entry.module),
|
|
icon: navigation.icon,
|
|
name,
|
|
pages: navigation.pages,
|
|
});
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
|
|
/**
|
|
* The API origin a Dashboard page was served with (ADR 0045).
|
|
*
|
|
* `declared` is the `content` of the page's `<meta name="ekklesia-api-origin">`, which the serving API
|
|
* fills in at startup. Absent or empty means the API shares this page's origin, the same-origin
|
|
* topology. Anything else must be exactly a canonical `http(s)` origin — no path, no trailing slash,
|
|
* no credentials, no query — or the result is `undefined` and the page must fail closed rather than
|
|
* send credentialed requests to an address it cannot vouch for.
|
|
*/
|
|
export function resolveApiOrigin(
|
|
declared: string | null | undefined,
|
|
pageOrigin: string,
|
|
): string | undefined {
|
|
if (declared === null || declared === undefined || declared === "") {
|
|
return pageOrigin;
|
|
}
|
|
let url: URL;
|
|
try {
|
|
url = new URL(declared);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.origin !== declared) {
|
|
return undefined;
|
|
}
|
|
return url.origin;
|
|
}
|
|
|
|
/** 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,
|
|
});
|
|
}
|
|
|
|
/** One module's settings destination, as the server evaluated it for this viewer (ADR 0061). */
|
|
export interface DashboardSettingsModule {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
/** `false` for a disabled or blocked module: its address says so, and navigation omits it. */
|
|
readonly available: boolean;
|
|
}
|
|
|
|
/** Which Settings destinations this viewer may see. Presentation hints, never authority. */
|
|
export interface DashboardSettings {
|
|
readonly sections: readonly SettingsSection[];
|
|
readonly modules: readonly DashboardSettingsModule[];
|
|
}
|
|
|
|
const settingsSectionNames: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
|
|
|
|
/**
|
|
* Strict, total validation of the Settings view: exact keys, known section names only and each at
|
|
* most once, and module ids and names as bounded strings. Anything else is a malformed response.
|
|
*/
|
|
export function parseDashboardSettings(value: unknown): DashboardSettings {
|
|
if (!hasExactKeys(value, ["modules", "sections"])) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
const { modules, sections } = value;
|
|
if (!Array.isArray(sections) || !Array.isArray(modules)) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
if (
|
|
!sections.every(
|
|
(section): section is SettingsSection =>
|
|
typeof section === "string" && settingsSectionNames.has(section),
|
|
) ||
|
|
new Set(sections).size !== sections.length
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
const parsedModules = modules.map((module): DashboardSettingsModule => {
|
|
if (
|
|
!hasExactKeys(module, ["available", "id", "name"]) ||
|
|
typeof module.id !== "string" ||
|
|
module.id.length === 0 ||
|
|
module.id.length > 64 ||
|
|
typeof module.name !== "string" ||
|
|
module.name.trim().length === 0 ||
|
|
module.name.length > 120 ||
|
|
typeof module.available !== "boolean"
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze({ available: module.available, id: module.id, name: module.name });
|
|
});
|
|
if (new Set(parsedModules.map((module) => module.id)).size !== parsedModules.length) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze({
|
|
modules: Object.freeze(parsedModules),
|
|
sections: Object.freeze([...sections]),
|
|
});
|
|
}
|
|
|
|
export async function loadDashboardSettings(
|
|
config: EkklesiaHostConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<DashboardSettings> {
|
|
return parseDashboardSettings(
|
|
await requestPlatformJson(config, DASHBOARD_SETTINGS_PATH, signal, {
|
|
forbidden: "HOST_ACCESS_DENIED",
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A signed-in person's own appearance and motion preferences, as their account stores them
|
|
* (ADR 0062). The value sets are the design tokens' own, so what the account stores is exactly what
|
|
* the theme controller applies.
|
|
*/
|
|
export interface HostAccountPreferences {
|
|
readonly appearance: EkklesiaAppearance;
|
|
readonly motion: EkklesiaMotionPreference;
|
|
}
|
|
|
|
/** Who is signed in, as the account area shows them: their name and email, and their preferences.
|
|
* Nothing else about the account reaches the browser. */
|
|
export interface HostAccount {
|
|
readonly name: string;
|
|
readonly email: string;
|
|
readonly preferences: HostAccountPreferences;
|
|
}
|
|
|
|
/** Strict: exactly the two preferences, each one of its known values. */
|
|
export function parseHostAccountPreferences(value: unknown): HostAccountPreferences {
|
|
if (
|
|
!hasExactKeys(value, ["appearance", "motion"]) ||
|
|
!EKKLESIA_APPEARANCES.includes(value.appearance as EkklesiaAppearance) ||
|
|
!EKKLESIA_MOTION_PREFERENCES.includes(value.motion as EkklesiaMotionPreference)
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze({
|
|
appearance: value.appearance as EkklesiaAppearance,
|
|
motion: value.motion as EkklesiaMotionPreference,
|
|
});
|
|
}
|
|
|
|
/** Strict: exactly name, email, and preferences. A name or email is rendered as text, never as
|
|
* markup or a URL, so the only requirement is that each is a string with something in it. */
|
|
export function parseHostAccount(value: unknown): HostAccount {
|
|
if (
|
|
!hasExactKeys(value, ["email", "name", "preferences"]) ||
|
|
typeof value.name !== "string" ||
|
|
typeof value.email !== "string" ||
|
|
value.email.trim().length === 0
|
|
) {
|
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
|
}
|
|
return Object.freeze({
|
|
email: value.email,
|
|
name: value.name,
|
|
preferences: parseHostAccountPreferences(value.preferences),
|
|
});
|
|
}
|
|
|
|
/** The signed-in person's own account. There is no way to ask for anyone else's. */
|
|
export async function loadHostAccount(
|
|
config: EkklesiaHostConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<HostAccount> {
|
|
return parseHostAccount(
|
|
await requestPlatformJson(config, ACCOUNT_PATH, signal, { forbidden: "HOST_ACCESS_DENIED" }),
|
|
);
|
|
}
|
|
|
|
/** Changes some of the signed-in person's own preferences and returns everything now in effect —
|
|
* the server's answer, which the shell applies, rather than what it asked for. */
|
|
export async function saveHostAccountPreferences(
|
|
config: EkklesiaHostConfig,
|
|
update: Partial<HostAccountPreferences>,
|
|
signal?: AbortSignal,
|
|
): Promise<HostAccountPreferences> {
|
|
return parseHostAccountPreferences(
|
|
await requestPlatformMutation(config, ACCOUNT_PREFERENCES_PATH, update, signal, "PATCH"),
|
|
);
|
|
}
|
|
|
|
export interface HostBootstrap {
|
|
readonly principal: ModuleUiPrincipal;
|
|
readonly navigation: DashboardNavigation;
|
|
/**
|
|
* The Settings destinations this viewer may see, or `"unavailable"` when they could not be
|
|
* loaded. Settings failing never stops the rest of the Dashboard from working: the Settings link
|
|
* is withheld and its pages say they could not be loaded.
|
|
*/
|
|
readonly settings: DashboardSettings | "unavailable";
|
|
/**
|
|
* The signed-in person's own account, or `"unavailable"` when it could not be loaded (and for an
|
|
* anonymous viewer, who has none). Like Settings, an account that cannot be loaded never stops the
|
|
* Dashboard: the account control shows no name, the theme stays as this browser last knew it, and
|
|
* the account pages say they could not be loaded.
|
|
*/
|
|
readonly account: HostAccount | "unavailable";
|
|
}
|
|
|
|
const EMPTY_SETTINGS: DashboardSettings = Object.freeze({
|
|
modules: Object.freeze([]),
|
|
sections: Object.freeze([]),
|
|
});
|
|
|
|
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 {
|
|
account: "unavailable",
|
|
navigation: EMPTY_NAVIGATION,
|
|
principal,
|
|
settings: EMPTY_SETTINGS,
|
|
};
|
|
}
|
|
const [navigation, settings, account] = await Promise.all([
|
|
loadDashboardNavigation(config, signal),
|
|
loadDashboardSettings(config, signal).catch((error: unknown) => {
|
|
if (signal?.aborted) {
|
|
throw error;
|
|
}
|
|
console.error("[ekklesia] settings navigation unavailable", error);
|
|
return "unavailable" as const;
|
|
}),
|
|
loadHostAccount(config, signal).catch((error: unknown) => {
|
|
if (signal?.aborted) {
|
|
throw error;
|
|
}
|
|
console.error("[ekklesia] account unavailable", error);
|
|
return "unavailable" as const;
|
|
}),
|
|
]);
|
|
return { account, navigation, principal, settings };
|
|
}
|