Files
Ekklesia/packages/module-ui-sdk/src/host-context.ts
T

159 lines
8.4 KiB
TypeScript

import type { ModuleUiContributions } from "./contribution.js";
import type { ModuleUiRealtimeClient } from "./realtime.js";
/**
* Who is viewing, as a browser sees it.
*
* This is deliberately **not** Core's `EkklesiaPrincipal`, and the difference is the point. That
* type's `userId` is a Zod-branded string whose brand only means something to server-side parsing,
* and reaching for it made this package import `@ekklesia/contracts` — a backend barrel whose type
* graph pulls Drizzle, and with it Node's global declarations, back into every module UI program
* (measured: a UI file importing this package could typecheck `process.env`). A browser contract
* that drags a database ORM's types behind it is not a browser contract.
*
* So the shape is preserved and the brand is dropped: plain, serializable, and exactly what a UI
* needs to decide what to render. An Ekklesia host can pass its own `EkklesiaPrincipal` straight in
* — a branded string is assignable to `string`, so the two remain structurally compatible without
* this package knowing anything about the server's representation. A standalone host builds the
* same object literal from whatever identity system it has.
*
* Nothing server-internal belongs here. A field is added only when module UI provably needs it to
* render something.
*/
export interface ModuleUiAnonymousPrincipal {
readonly kind: "anonymous";
}
export interface ModuleUiAuthenticatedPrincipal {
readonly kind: "authenticated";
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
readonly userId: string;
}
/**
* A managed display device — a screen the platform paired, with no person behind it.
*
* Module UI is told this so it can render honestly: a signage view has nobody to greet, no personal
* data to show, and no interaction to invite. It is deliberately *not* an authenticated principal,
* because module code that asks "is someone signed in?" must not be told yes about a television.
*
* There is nothing here about pairing. How a device came to exist is the host's business; a module
* receives an identity and its permissions, exactly as it does for a person.
*/
export interface ModuleUiDevicePrincipal {
readonly kind: "device";
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
readonly deviceId: string;
}
export type ModuleUiPrincipal =
| ModuleUiAnonymousPrincipal
| ModuleUiAuthenticatedPrincipal
| ModuleUiDevicePrincipal;
/**
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
*
* This is deliberately the smallest set that proves the architecture rather than everything a real
* screen will eventually want. Navigation, module configuration, organization/platform context, and
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
*
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
* root, they inherit into the artifact's own root, and module code writes `var(--ekklesia-…)`. A
* token map on this context would make styling a run-time negotiation, force every module to
* re-implement it from data, and give a host something it could forget to supply.
*
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
* supplied to primary and display artifacts: there is deliberately no role or presentation-mode
* flag, because the separately declared artifact already selects the experience. This package has
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
*/
export interface ModuleUiHostContext {
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
* or re-derive its own identity from a URL. */
readonly moduleId: string;
/**
* The host-owned public page for this module's dedicated display, when one exists.
*
* The URL is already absolute and deployment-correct. A module may show, open, or copy it, but
* must never construct a platform route or combine its own id with a browser origin. A standalone
* host can provide its own display page here without imitating Ekklesia's routing.
*/
readonly display: ModuleUiDisplayLocation | undefined;
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
readonly api: ModuleUiApiClient;
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
* a browser-owned shape rather than Core's server principal type. */
readonly principal: ModuleUiPrincipal;
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
readonly permissions: ModuleUiPermissions;
/** Host-owned delivery of this module's own runtime-published state. */
readonly realtime: ModuleUiRealtimeClient;
/**
* The renderers other modules contributed to capabilities **this** module provides (ADR 0041).
*
* Present on every context, and empty for the overwhelming majority of modules — a module that
* provides no extension point simply finds nothing in it. It is the one member that lets one
* module's UI extend another's without either importing the other, and it deliberately exposes
* identity and a mount operation rather than a URL: the host resolves publication, exactly as it
* resolves `api.request`.
*/
readonly contributions: ModuleUiContributions;
}
export interface ModuleUiDisplayLocation {
readonly url: string;
}
/**
* A module UI's access to its own module's HTTP API, addressed **relative to the module's own API
* root** — never as an absolute path.
*
* This is the single most important portability rule in this contract. Ekklesia mounts module routes
* under `/api/v1/modules/<module-id>` (`apps/api/src/module-http-routes.ts`), but that prefix is
* Ekklesia's private deployment detail. A standalone host serving one module may expose the same
* routes at `/api`, at a different origin entirely, or through a proxy. A module UI that builds
* `/api/v1/modules/announcements/posts` itself is silently welded to Ekklesia and can never be
* hosted anywhere else.
*
* So the module asks for `posts`, and the host resolves it. `Request`/`Response` are the same Fetch
* vocabulary a module's backend routes already speak (ADR 0015), so one module speaks one HTTP
* shape on both sides of the wire.
*
* A host is responsible for credentials, base URL, and any platform headers. A module UI neither
* supplies nor sees a session token.
*/
export interface ModuleUiApiClient {
/**
* @param path A path relative to this module's own API root, with or without a leading `/`.
* Must not be an absolute URL: a host is free to reject one rather than silently allowing a
* module UI to address something other than its own backend.
*/
request(path: string, init?: RequestInit): Promise<Response>;
}
/**
* Presentation-only permission hints.
*
* **This is not a security boundary and must never be used as one.** A module UI may use it to hide
* an action, disable a control, or choose which empty state to render. Server-side authorization
* remains the only authority: every module route is enforced by Core's `requirePermission`
* middleware against the composed platform permission registry (ADR 0013/0014), and that
* enforcement is unchanged by, and unaware of, anything a browser believes.
*
* A module UI that "checks" a permission here and then calls its API is still fully authorized
* server-side. A module UI that skips the check and calls anyway is still denied server-side. The
* value of this interface is that the user does not see a button that would fail.
*
* `permissionId` is a plain `string`, deliberately, and not the branded `PermissionId` Core uses:
* branding would require a module UI to run Zod parsing in the browser to produce a value for a
* call that decides nothing security-relevant. The branded type remains the server-side type.
*/
export interface ModuleUiPermissions {
/** Whether the host believes the current principal holds this permission. A hint, not a grant. */
has(permissionId: string): boolean;
}