CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
164 lines
8.8 KiB
TypeScript
164 lines
8.8 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.
|
|
*
|
|
* The module's API may be on another origin than the page (Ekklesia's split topology, ADR 0045), so
|
|
* a portable module UI keeps to what cross-origin requests always allow: CORS-safelisted request
|
|
* headers plus `Content-Type`, and only CORS-safelisted response headers read back. Anything more
|
|
* works under one host topology and fails under another.
|
|
*/
|
|
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;
|
|
}
|