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 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; } /** * 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/` (`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; } /** * 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; }