feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fdaad58848
commit
baa689b2a7
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleSecretSource,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -11,7 +12,11 @@ import type {
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationSettings,
|
||||
ModuleConfigurationStore,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleLifecycleStore,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -22,6 +27,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
@@ -68,6 +74,25 @@ export interface CreateApiAppOptions {
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
};
|
||||
/**
|
||||
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
|
||||
* manifests declare what may be configured, the settings this process resolved at boot (used only
|
||||
* to compute `restartRequired`), the deployment's secret source, and the store.
|
||||
*
|
||||
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
|
||||
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
|
||||
* then reported `secret_missing` rather than silently starting without it.
|
||||
*/
|
||||
readonly moduleConfiguration: {
|
||||
readonly discoveredModules: readonly DiscoveredModule[];
|
||||
readonly bootSettings: ModuleConfigurationSettings;
|
||||
readonly secrets: ModuleSecretSource;
|
||||
readonly store: ModuleConfigurationStore;
|
||||
/** The per-module frozen snapshots the started runtime is actually using — the identical map
|
||||
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
|
||||
* read the same object rather than two independently resolved values. */
|
||||
readonly snapshots: ModuleConfigurationLookup;
|
||||
};
|
||||
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
||||
* rather than defaulted, like every other module option here: a caller that exercises no
|
||||
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
||||
@@ -244,13 +269,34 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
configuration: {
|
||||
secrets: options.moduleConfiguration.secrets,
|
||||
store: options.moduleConfiguration.store,
|
||||
},
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
store: options.moduleLifecycle.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
|
||||
// to installed modules. See `./module-configuration-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createModuleConfigurationRoutes({
|
||||
bootSettings: options.moduleConfiguration.bootSettings,
|
||||
discoveredModules: options.moduleConfiguration.discoveredModules,
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
secrets: options.moduleConfiguration.secrets,
|
||||
store: options.moduleConfiguration.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||
configurations: options.moduleConfiguration.snapshots,
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleConfigurationView,
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleConfigurationDrizzleDatabase,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
@@ -20,6 +22,7 @@ export interface ApiDatabaseHandle {
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
@@ -31,7 +34,8 @@ export interface ApiDatabaseHandle {
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleDatabaseView`). No domain
|
||||
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
|
||||
* `createModuleDatabaseView`). No domain
|
||||
* owns or closes the pool in this composition — `close()`
|
||||
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||
@@ -49,6 +53,7 @@ export function createApiDatabase(
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
type ModuleConfigurationDeclaration,
|
||||
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
resolveModuleConfigurationFor,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
/**
|
||||
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
|
||||
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
|
||||
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
|
||||
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
|
||||
* platform's own configuration mutation there would put Core and an installed module in one URL
|
||||
* namespace with registration order deciding which one answers — for a route that changes how
|
||||
* modules behave.
|
||||
*/
|
||||
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
|
||||
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
|
||||
|
||||
/**
|
||||
* One declared key as an operator sees it: the module's own declaration, plus what is currently
|
||||
* stored, plus what the running process actually used.
|
||||
*
|
||||
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
|
||||
* absent, because there is no code path that could put secret material here: Ekklesia never persists
|
||||
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
|
||||
* supplied one, which is the only thing an operator needs to know from here.
|
||||
*/
|
||||
export interface ModuleConfigurationKeyView {
|
||||
readonly key: string;
|
||||
readonly type: ModuleConfigurationDeclaration["type"];
|
||||
readonly required: boolean;
|
||||
readonly description?: string;
|
||||
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
|
||||
readonly default?: string | number | boolean;
|
||||
/** Present only for `type: "enum"`. */
|
||||
readonly values?: readonly string[];
|
||||
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
|
||||
* stored" — the default applies, or the key is unset. */
|
||||
readonly value?: string;
|
||||
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
|
||||
* Never implies the value is valid — see the module's `runtime` entry for that. */
|
||||
readonly configured: boolean;
|
||||
}
|
||||
|
||||
export interface ModuleConfigurationView {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly keys: readonly ModuleConfigurationKeyView[];
|
||||
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
|
||||
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
|
||||
* rather than merely tolerated. */
|
||||
readonly unknownKeys: readonly string[];
|
||||
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
|
||||
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The discovered inventory this process booted from — the source of every declaration this route
|
||||
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
|
||||
* the running platform is using. */
|
||||
readonly discoveredModules: readonly DiscoveredModule[];
|
||||
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
|
||||
* comparison with what is stored now. */
|
||||
readonly bootSettings: ModuleConfigurationSettings;
|
||||
readonly secrets: ModuleSecretSource;
|
||||
readonly store: ModuleConfigurationStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
|
||||
* errors never carry a value, so nothing here can leak one either. */
|
||||
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||
if (error instanceof ModuleConfigurationStoreError) {
|
||||
if (
|
||||
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
|
||||
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
|
||||
) {
|
||||
return sanitizedFailure(c, 503, error.code);
|
||||
}
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
return sanitizedFailure(
|
||||
c,
|
||||
503,
|
||||
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const BODY_KEYS = new Set(["values"]);
|
||||
|
||||
/**
|
||||
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
|
||||
*
|
||||
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
|
||||
* — and converts to the single canonical text form the store persists and the resolver parses. It
|
||||
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
|
||||
* row can never validate differently from the request that created it.
|
||||
*/
|
||||
function canonicalStoredValue(
|
||||
declaration: ModuleConfigurationDeclaration,
|
||||
submitted: unknown,
|
||||
): string | undefined {
|
||||
switch (declaration.type) {
|
||||
case "integer": {
|
||||
return typeof submitted === "number" && Number.isSafeInteger(submitted)
|
||||
? String(submitted)
|
||||
: undefined;
|
||||
}
|
||||
case "boolean": {
|
||||
return typeof submitted === "boolean" ? String(submitted) : undefined;
|
||||
}
|
||||
case "string":
|
||||
case "enum":
|
||||
case "url": {
|
||||
return typeof submitted === "string" &&
|
||||
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
|
||||
? submitted
|
||||
: undefined;
|
||||
}
|
||||
case "secret": {
|
||||
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
|
||||
// type to the union is a compile error rather than a silent acceptance of secret material.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(c: Context): Promise<unknown> {
|
||||
try {
|
||||
return (await c.req.json()) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function declarationView(
|
||||
declaration: ModuleConfigurationDeclaration,
|
||||
storedValue: string | undefined,
|
||||
secretConfigured: boolean,
|
||||
): ModuleConfigurationKeyView {
|
||||
const isSecret = declaration.type === "secret";
|
||||
return {
|
||||
// A secret is `configured` when the deployment supplies it; anything else when a value is
|
||||
// stored. The two are different questions with one honest answer each.
|
||||
configured: isSecret ? secretConfigured : storedValue !== undefined,
|
||||
key: declaration.key,
|
||||
required: declaration.required,
|
||||
type: declaration.type,
|
||||
...(declaration.description === undefined ? {} : { description: declaration.description }),
|
||||
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
|
||||
...(declaration.type === "enum" ? { values: declaration.values } : {}),
|
||||
// The one place a stored value reaches a response — and structurally never for a secret.
|
||||
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's module configuration resource: what each installed module declares it needs, what
|
||||
* an operator has stored, and whether the running process is still using it.
|
||||
*
|
||||
* ## Secrets are absent, not filtered
|
||||
*
|
||||
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
|
||||
* persists secret material, so the store this route reads from contains none. A `secret` key's
|
||||
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
|
||||
* secret source a yes/no question. A future code change that tried to serve a secret value would
|
||||
* have to first invent somewhere to read one from.
|
||||
*
|
||||
* ## Changing configuration does not change the running process
|
||||
*
|
||||
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
|
||||
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
|
||||
* `restartRequired` rather than pretending the change took effect.
|
||||
*
|
||||
* ## Closed validation against the module's own declaration
|
||||
*
|
||||
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
|
||||
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
|
||||
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
|
||||
* no supported way to submit secret material to this platform.
|
||||
*/
|
||||
export function createModuleConfigurationRoutes(
|
||||
options: CreateModuleConfigurationRoutesOptions,
|
||||
): Hono {
|
||||
const routes = new Hono();
|
||||
const requireManage = requirePermission(
|
||||
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
const modulesById = new Map(
|
||||
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
|
||||
);
|
||||
|
||||
const storedFor = (
|
||||
settings: ModuleConfigurationSettings,
|
||||
moduleId: string,
|
||||
): ReadonlyMap<string, string> =>
|
||||
new Map(
|
||||
settings.records
|
||||
.filter((record) => record.moduleId === moduleId)
|
||||
.map((record) => [record.key, record.value] as const),
|
||||
);
|
||||
|
||||
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
|
||||
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
|
||||
* than a guess. */
|
||||
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
|
||||
const boot = storedFor(options.bootSettings, moduleId);
|
||||
if (boot.size !== current.size) {
|
||||
return true;
|
||||
}
|
||||
for (const [key, value] of current) {
|
||||
if (boot.get(key) !== value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildView = (
|
||||
module: DiscoveredModule,
|
||||
settings: ModuleConfigurationSettings,
|
||||
): ModuleConfigurationView => {
|
||||
const stored = storedFor(settings, module.manifest.id);
|
||||
const resolved = resolveModuleConfigurationFor(module.manifest, {
|
||||
secrets: options.secrets,
|
||||
settings,
|
||||
});
|
||||
return {
|
||||
keys: module.manifest.configuration.map((declaration) =>
|
||||
declarationView(
|
||||
declaration as ModuleConfigurationDeclaration,
|
||||
stored.get(declaration.key),
|
||||
// Asked, never read into the response: the answer is a boolean, and the value it was
|
||||
// derived from never leaves the resolver.
|
||||
declaration.type === "secret"
|
||||
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
|
||||
: false,
|
||||
),
|
||||
),
|
||||
moduleId: module.manifest.id,
|
||||
restartRequired: differsFromBoot(module.manifest.id, stored),
|
||||
unknownKeys: resolved.unknownKeys,
|
||||
version: module.manifest.version,
|
||||
};
|
||||
};
|
||||
|
||||
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||
}
|
||||
try {
|
||||
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "read");
|
||||
}
|
||||
});
|
||||
|
||||
routes.put(
|
||||
MODULE_CONFIGURATION_MODULE_PATH,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const raw = await readJsonBody(c);
|
||||
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const submitted = raw.values;
|
||||
if (!isRecord(submitted)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||
}
|
||||
|
||||
const declarations = new Map(
|
||||
module.manifest.configuration.map(
|
||||
(declaration) =>
|
||||
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
|
||||
),
|
||||
);
|
||||
const values: { key: string; value: string }[] = [];
|
||||
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
|
||||
// re-validated against the platform's one configuration-key grammar before it is used, so a
|
||||
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
|
||||
for (const key of Object.keys(submitted)) {
|
||||
if (!moduleConfigurationKeySchema.safeParse(key).success) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const declaration = declarations.get(key);
|
||||
if (!declaration) {
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
|
||||
}
|
||||
if (declaration.type === "secret") {
|
||||
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
|
||||
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
|
||||
// the deployment environment (ADR 0029).
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
|
||||
}
|
||||
const canonical = canonicalStoredValue(declaration, submitted[key]);
|
||||
if (canonical === undefined) {
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||
}
|
||||
values.push({ key, value: canonical });
|
||||
}
|
||||
|
||||
// Constraint validation runs through the **same resolver the platform boots with**, over the
|
||||
// exact document about to be stored, rather than a second copy of the type rules here. That
|
||||
// is what guarantees the API and startup can never disagree about whether a value is valid:
|
||||
// there is one implementation, and this is it.
|
||||
//
|
||||
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
|
||||
// the module is then reported `blocked · configuration_missing` until they finish. An
|
||||
// *invalid* one never is: storing a value that could not survive the next boot would hand
|
||||
// back a success for a change that silently breaks the module.
|
||||
const candidate = resolveModuleConfigurationFor(module.manifest, {
|
||||
secrets: options.secrets,
|
||||
settings: {
|
||||
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
|
||||
},
|
||||
});
|
||||
if (candidate.issue?.reason === "configuration_invalid") {
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||
}
|
||||
|
||||
try {
|
||||
await options.store.replace({ moduleId: module.manifest.id, values });
|
||||
// Re-read rather than predict, so this response and a later GET can never disagree.
|
||||
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "write");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
ModuleId,
|
||||
ModuleRouteHandlerContext,
|
||||
ModuleRuntimeServices,
|
||||
RegisteredModuleHttpRoute,
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
type ModuleId,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleConfigurationLookup } from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
@@ -31,6 +33,16 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||
* after authorization succeeds. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
/**
|
||||
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||
* rather than two independently resolved values. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
||||
*
|
||||
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
|
||||
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
||||
*/
|
||||
readonly configurations: ModuleConfigurationLookup;
|
||||
}
|
||||
|
||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||
@@ -212,6 +224,7 @@ export function mountModuleHttpRoutes(
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: route.moduleId,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
|
||||
@@ -2,9 +2,11 @@ import type {
|
||||
ModuleBlockedReason,
|
||||
ModuleBlockingDependency,
|
||||
ModuleRuntimeState,
|
||||
ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
type ModuleConfigurationStore,
|
||||
type ModuleLifecycleSnapshot,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
@@ -58,7 +60,11 @@ export interface ModuleLifecycleModuleView {
|
||||
readonly runtime: {
|
||||
readonly state: ModuleRuntimeState;
|
||||
readonly reason?: ModuleBlockedReason;
|
||||
/** Present for a dependency reason. */
|
||||
readonly dependency?: ModuleBlockingDependency;
|
||||
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
|
||||
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
@@ -74,6 +80,16 @@ export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlew
|
||||
* if it were restarted right now. */
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
/**
|
||||
* How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own
|
||||
* configuration can block it, `restartRequired` must be computed against live configuration as
|
||||
* well as live enablement — otherwise correcting a missing required value would report no pending
|
||||
* restart for a module the next boot really will start.
|
||||
*/
|
||||
readonly configuration: {
|
||||
readonly store: ModuleConfigurationStore;
|
||||
readonly secrets: ModuleSecretSource;
|
||||
};
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
@@ -137,6 +153,9 @@ function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
||||
state: view.runtime.state,
|
||||
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
||||
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
||||
...(view.runtime.configurationKey === undefined
|
||||
? {}
|
||||
: { configurationKey: view.runtime.configurationKey }),
|
||||
},
|
||||
version: view.version,
|
||||
};
|
||||
@@ -197,8 +216,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes
|
||||
// every other authorized Core read does.
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
try {
|
||||
const configuration = await options.store.readConfiguration();
|
||||
const modules = resolveModuleLifecycleView(options.snapshot, configuration).map(present);
|
||||
const [configuration, settings] = await Promise.all([
|
||||
options.store.readConfiguration(),
|
||||
options.configuration.store.readSettings(),
|
||||
]);
|
||||
const modules = resolveModuleLifecycleView(options.snapshot, configuration, {
|
||||
secrets: options.configuration.secrets,
|
||||
settings,
|
||||
}).map(present);
|
||||
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "read");
|
||||
@@ -225,10 +250,14 @@ export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutes
|
||||
// Re-read rather than predict: the response describes the configuration that is actually
|
||||
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
||||
// endpoints can never disagree about `restartRequired`.
|
||||
const configuration = await options.store.readConfiguration();
|
||||
const view = resolveModuleLifecycleView(options.snapshot, configuration).find(
|
||||
(entry) => entry.moduleId === moduleId,
|
||||
);
|
||||
const [configuration, settings] = await Promise.all([
|
||||
options.store.readConfiguration(),
|
||||
options.configuration.store.readSettings(),
|
||||
]);
|
||||
const view = resolveModuleLifecycleView(options.snapshot, configuration, {
|
||||
secrets: options.configuration.secrets,
|
||||
settings,
|
||||
}).find((entry) => entry.moduleId === moduleId);
|
||||
if (!view) {
|
||||
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
||||
}
|
||||
|
||||
+55
-6
@@ -1,7 +1,11 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
@@ -9,9 +13,12 @@ import {
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createEnvironmentModuleSecretSource,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleEligibilityResolution,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
@@ -142,6 +149,17 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||
readonly moduleHookTimeoutMsOverride?: number;
|
||||
/**
|
||||
* Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency
|
||||
* in this options object rather than a test-only escape hatch.
|
||||
*
|
||||
* It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a
|
||||
* validated value, and because the set of secrets depends on which modules are discovered — which
|
||||
* is not known when configuration is validated. The executable entry point supplies an
|
||||
* environment-backed source; omitting it supplies nothing, so a module declaring a required secret
|
||||
* is reported `secret_missing` rather than silently starting without it.
|
||||
*/
|
||||
readonly moduleSecrets?: ModuleSecretSource;
|
||||
}
|
||||
|
||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
@@ -405,12 +423,28 @@ export async function startApi(
|
||||
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
||||
// module an administrator disabled is worse than not starting at all.
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||
databaseHandle.moduleConfiguration,
|
||||
);
|
||||
const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE;
|
||||
let eligibility: ModuleEligibilityResolution;
|
||||
let moduleConfigurationSettings: ModuleConfigurationSettings;
|
||||
try {
|
||||
eligibility = resolveModuleEligibility(
|
||||
inventory.modules,
|
||||
await moduleLifecycleStore.readConfiguration(),
|
||||
);
|
||||
// Both axes are read before a single module entrypoint is imported, and resolved together in
|
||||
// one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own
|
||||
// required configuration is satisfiable, and every module it requires is eligible too. The pass
|
||||
// also produces each eligible module's frozen configuration snapshot, so the configuration that
|
||||
// made a module eligible is the identical object it later receives.
|
||||
// Read sequentially rather than concurrently, deliberately: these are two tiny queries, and
|
||||
// racing them would make the reported failure depend on which rejected first. An operator
|
||||
// debugging a broken deployment must get the same error code every time.
|
||||
const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration();
|
||||
const settings = await moduleConfigurationStore.readSettings();
|
||||
moduleConfigurationSettings = settings;
|
||||
eligibility = resolveModuleEligibility(inventory.modules, lifecycleConfiguration, {
|
||||
secrets: moduleSecrets,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
@@ -427,6 +461,7 @@ export async function startApi(
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||
configurations: eligibility.configurations,
|
||||
...(options.moduleHookTimeoutMsOverride === undefined
|
||||
? {}
|
||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||
@@ -463,6 +498,15 @@ export async function startApi(
|
||||
}),
|
||||
store: moduleLifecycleStore,
|
||||
},
|
||||
moduleConfiguration: {
|
||||
// The settings this process actually resolved from, so `restartRequired` is a comparison
|
||||
// against what is running rather than a guess.
|
||||
bootSettings: moduleConfigurationSettings,
|
||||
discoveredModules: inventory.modules,
|
||||
secrets: moduleSecrets,
|
||||
snapshots: eligibility.configurations,
|
||||
store: moduleConfigurationStore,
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
runtime,
|
||||
@@ -615,7 +659,12 @@ function readConfigFromEnvironment(): {
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input } = readConfigFromEnvironment();
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
const running = await startApi(config);
|
||||
// The one place this process turns its environment into module secrets. Core's factory takes the
|
||||
// environment as an argument and never touches `process.env` itself, preserving the rule that
|
||||
// only executables read the environment.
|
||||
const running = await startApi(config, {
|
||||
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
|
||||
});
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
|
||||
@@ -196,6 +196,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -301,7 +302,7 @@ async function createGatedRouteModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -342,7 +343,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -434,7 +435,7 @@ async function createUiModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -1102,12 +1103,14 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
||||
{ environment: "development" },
|
||||
);
|
||||
|
||||
// Startup reads the stored module enablement configuration before importing any module
|
||||
// entrypoint (ADR 0028). Against an unmigrated database that read cannot succeed, and the
|
||||
// platform fails closed rather than starting every discovered module: it cannot know what an
|
||||
// administrator decided, and starting a module they disabled is worse than not starting.
|
||||
// The failure is Core's own sanitized store error — no connection string, host, SQL, driver
|
||||
// message, or stack.
|
||||
// Startup reads the stored module enablement configuration and then the stored module
|
||||
// configuration, both before importing any module entrypoint (ADR 0028, ADR 0029). Against an
|
||||
// unmigrated database neither read can succeed, and the platform fails closed rather than
|
||||
// starting every discovered module: it cannot know what an administrator decided, and starting
|
||||
// a module they disabled — or one whose required configuration is absent — is worse than not
|
||||
// starting. The two reads are sequential rather than raced precisely so this error code is
|
||||
// deterministic; the failure is Core's own sanitized store error, with no connection string,
|
||||
// host, SQL, driver message, or stack.
|
||||
let captured: unknown;
|
||||
try {
|
||||
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
@@ -2189,10 +2192,10 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own four (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`) plus the fixture
|
||||
// module's one.
|
||||
details: ["5 permissions registered"],
|
||||
// Core's own five (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||
// `core.modules.configuration.manage`) plus the fixture module's one.
|
||||
details: ["6 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-stor
|
||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -90,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -110,6 +112,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -131,6 +134,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -154,6 +158,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -179,6 +184,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -204,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -226,6 +233,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -250,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -271,6 +280,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -294,6 +304,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -324,6 +335,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -355,6 +367,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -391,6 +404,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -409,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -429,6 +444,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -448,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -465,6 +482,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -489,6 +507,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -516,6 +535,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
@@ -546,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
@@ -567,6 +588,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -72,7 +73,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -145,6 +146,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -215,7 +215,7 @@ function fakeDiscoveredModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import {
|
||||
createFakeModuleSecretSource,
|
||||
createInMemoryModuleConfigurationStore,
|
||||
} from "./support/in-memory-module-configuration.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
|
||||
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
|
||||
* storage credential.
|
||||
*
|
||||
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
|
||||
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
|
||||
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
|
||||
* platform infrastructure.
|
||||
*/
|
||||
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
|
||||
{
|
||||
key: "slideSeconds",
|
||||
type: "integer",
|
||||
default: 7,
|
||||
minimum: 1,
|
||||
maximum: 600,
|
||||
description: "How long each slide is shown.",
|
||||
},
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
];
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${fixture.id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
readonly fixtures?: readonly ModuleFixture[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly platformOwner?: boolean;
|
||||
readonly grants?: readonly string[];
|
||||
readonly records?: readonly ModuleConfigurationRecord[];
|
||||
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||
readonly store?: ModuleConfigurationStore;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly store: ModuleConfigurationStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
|
||||
* build the UI registry and every option from that same resolution. Doing it the same way here is
|
||||
* what makes these assertions evidence about production composition rather than a bespoke wiring.
|
||||
*/
|
||||
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures ?? [
|
||||
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||
]) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
|
||||
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
|
||||
const lifecycleStore = createInMemoryModuleLifecycleStore();
|
||||
const bootSettings = await store.readSettings();
|
||||
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
|
||||
secrets,
|
||||
settings: bootSettings,
|
||||
});
|
||||
|
||||
const held = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const authorizationStore = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleConfiguration: {
|
||||
bootSettings,
|
||||
discoveredModules: modules,
|
||||
secrets,
|
||||
snapshots: eligibility.configurations,
|
||||
store,
|
||||
},
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||
},
|
||||
store: lifecycleStore,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app, modules, store };
|
||||
}
|
||||
|
||||
function owner(): InstallOptions {
|
||||
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||
}
|
||||
|
||||
interface ConfigurationBody {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly keys: readonly {
|
||||
readonly key: string;
|
||||
readonly type: string;
|
||||
readonly required: boolean;
|
||||
readonly description?: string;
|
||||
readonly default?: string | number | boolean;
|
||||
readonly values?: readonly string[];
|
||||
readonly value?: string;
|
||||
readonly configured: boolean;
|
||||
}[];
|
||||
readonly unknownKeys: readonly string[];
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
async function readConfiguration(
|
||||
app: Hono,
|
||||
moduleId = "announcements",
|
||||
): Promise<ConfigurationBody> {
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as ConfigurationBody;
|
||||
}
|
||||
|
||||
function putConfiguration(
|
||||
app: Hono,
|
||||
values: unknown,
|
||||
moduleId = "announcements",
|
||||
origin = trustedOrigin,
|
||||
) {
|
||||
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||
body: JSON.stringify({ values }),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/v1/module-configuration/:moduleId", () => {
|
||||
it("returns the module's declared schema with defaults and stored values", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.moduleId).toBe("announcements");
|
||||
expect(body.keys.map((entry) => entry.key)).toEqual([
|
||||
"slideSeconds",
|
||||
"pollIntervalSeconds",
|
||||
"bucket",
|
||||
"fit",
|
||||
"storageCredential",
|
||||
]);
|
||||
expect(body.keys[0]).toEqual({
|
||||
configured: false,
|
||||
default: 7,
|
||||
description: "How long each slide is shown.",
|
||||
key: "slideSeconds",
|
||||
required: false,
|
||||
type: "integer",
|
||||
});
|
||||
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
|
||||
configured: true,
|
||||
required: true,
|
||||
value: "slides",
|
||||
});
|
||||
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
|
||||
});
|
||||
|
||||
it("never returns a secret value, only whether the deployment supplies one", async () => {
|
||||
const configured = await install({
|
||||
...owner(),
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
const body = await readConfiguration(configured.app);
|
||||
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||
|
||||
expect(secretEntry).toEqual({
|
||||
configured: true,
|
||||
key: "storageCredential",
|
||||
required: true,
|
||||
type: "secret",
|
||||
});
|
||||
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
|
||||
// string appears nowhere in the payload.
|
||||
expect(secretEntry).not.toHaveProperty("value");
|
||||
expect(secretEntry).not.toHaveProperty("default");
|
||||
expect(JSON.stringify(body)).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("reports an unsupplied secret as not configured, still without a value field", async () => {
|
||||
const { app } = await install(owner());
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
|
||||
configured: false,
|
||||
key: "storageCredential",
|
||||
required: true,
|
||||
type: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists stored keys the current manifest no longer declares, without failing", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
|
||||
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
|
||||
});
|
||||
|
||||
it("returns a module declaring no configuration as an empty, valid document", async () => {
|
||||
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
|
||||
const body = await readConfiguration(app, "calendar");
|
||||
|
||||
expect(body.keys).toEqual([]);
|
||||
expect(body.unknownKeys).toEqual([]);
|
||||
expect(body.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a module this process did not discover", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||
});
|
||||
|
||||
it("is not cached and varies on origin and cookie", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
|
||||
const failing: ModuleConfigurationStore = {
|
||||
readSettings: async () => {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
|
||||
},
|
||||
replace: async () => {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
|
||||
},
|
||||
};
|
||||
let bootRead = false;
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
store: {
|
||||
readSettings: async () => {
|
||||
if (!bootRead) {
|
||||
bootRead = true;
|
||||
return { records: [] };
|
||||
}
|
||||
return failing.readSettings();
|
||||
},
|
||||
replace: (input) => failing.replace(input),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/v1/module-configuration/:moduleId", () => {
|
||||
it("stores values and reports that a restart is required", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ConfigurationBody;
|
||||
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
|
||||
configured: true,
|
||||
value: "12",
|
||||
});
|
||||
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
|
||||
expect(body.restartRequired).toBe(true);
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no pending restart when the submitted document matches what booted", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides" });
|
||||
|
||||
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the whole document, so an omitted key is cleared", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
],
|
||||
});
|
||||
|
||||
await putConfiguration(app, { bucket: "slides" });
|
||||
|
||||
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
|
||||
});
|
||||
|
||||
it("never touches another module's stored values", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
fixtures: [
|
||||
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
|
||||
],
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
],
|
||||
});
|
||||
|
||||
await putConfiguration(app, { bucket: "renamed" });
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to accept secret material over HTTP", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, {
|
||||
bucket: "slides",
|
||||
storageCredential: "s3cret",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
|
||||
// Nothing was written at all — not the secret, and not the valid key beside it.
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a key the module does not declare", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
|
||||
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
|
||||
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
|
||||
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
|
||||
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
|
||||
["a string shorter than its declared minLength", { bucket: "" }],
|
||||
["a nested object", { bucket: { name: "slides" } }],
|
||||
["an array", { bucket: ["slides"] }],
|
||||
["null", { bucket: null }],
|
||||
])("rejects %s without storing anything", async (_label, values) => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, values);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a body with an unexpected top-level key", { values: {}, force: true }],
|
||||
["a body with no values object", {}],
|
||||
["a bare array body", []],
|
||||
["a bare string body", "bucket=slides"],
|
||||
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
])("rejects the reserved property name %s as a submitted key", async (key) => {
|
||||
const { app, store } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
// Nothing about the request reached `Object.prototype`.
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
// `bucket` is required and absent: an operator may save partial work.
|
||||
const response = await putConfiguration(app, { slideSeconds: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(
|
||||
app,
|
||||
{ bucket: "slides" },
|
||||
"announcements",
|
||||
untrustedOrigin,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing origin", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: JSON.stringify({ values: { bucket: "slides" } }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||
const { app } = await install(owner());
|
||||
const oversized = JSON.stringify({
|
||||
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
|
||||
});
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: oversized,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration authority", () => {
|
||||
it("refuses an anonymous caller on both verbs", async () => {
|
||||
const { app } = await install();
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
|
||||
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
|
||||
const { app } = await install({
|
||||
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
|
||||
// The two authorities are deliberately separate: tuning how a module behaves and deciding
|
||||
// whether it runs at all are different decisions (ADR 0029).
|
||||
const lifecycleOnly = await install({
|
||||
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
expect(
|
||||
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||
).toBe(403);
|
||||
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||
|
||||
const configurationOnly = await install({
|
||||
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
|
||||
expect(
|
||||
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration state is visible through the lifecycle resource", () => {
|
||||
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
readonly modules: readonly {
|
||||
readonly id: string;
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(body.modules[0]?.runtime).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
state: "blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
const raw = await response.text();
|
||||
|
||||
expect(raw).toContain("secret_missing");
|
||||
expect(raw).toContain("storageCredential");
|
||||
});
|
||||
|
||||
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
fixtures: [
|
||||
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
|
||||
],
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
|
||||
// not eligible, so it is not published.
|
||||
expect(inventory.modules).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no secret or configuration value leaks through any surface", () => {
|
||||
it("keeps secret material out of every response body this resource produces", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
secrets: { announcements: { storageCredential: "super-secret-value" } },
|
||||
});
|
||||
|
||||
const listed = await (
|
||||
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
})
|
||||
).text();
|
||||
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
|
||||
const lifecycle = await (
|
||||
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||
).text();
|
||||
|
||||
for (const payload of [listed, mutated, lifecycle]) {
|
||||
expect(payload).not.toContain("super-secret-value");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
|
||||
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
|
||||
// resolver, which never reads one. This plants the row directly to prove the *serialization*
|
||||
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
|
||||
// branch someone could remove.
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||
|
||||
expect(secretEntry).not.toHaveProperty("value");
|
||||
expect(secretEntry?.configured).toBe(false);
|
||||
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
|
||||
});
|
||||
|
||||
it("never leaks a filesystem path, package name, or manifest path", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const listed = await (
|
||||
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
})
|
||||
).text();
|
||||
|
||||
expect(listed).not.toContain("/tmp");
|
||||
expect(listed).not.toContain("@test/module-");
|
||||
expect(listed).not.toContain("ekklesia.module.json");
|
||||
expect(listed).not.toContain("dist/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the configuration prefix is a sibling of the module route namespace", () => {
|
||||
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||
// `config` and `configuration` are ordinary lowercase static segments under
|
||||
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
|
||||
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
|
||||
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
|
||||
|
||||
const { app } = await install(owner());
|
||||
const collision = await app.request("/api/v1/modules/announcements/config", {
|
||||
body: JSON.stringify({ values: {} }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(collision.status).toBe(404);
|
||||
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
@@ -222,6 +222,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -249,6 +250,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -517,7 +519,13 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
const readHandlerEvent = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
|
||||
expect(readHandlerEvent?.contextKeys).toEqual([
|
||||
"config",
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"services",
|
||||
]);
|
||||
expect(readHandlerEvent?.services).toBeDefined();
|
||||
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ModuleRouteConfigurationError,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -111,6 +112,7 @@ function buildModuleApp(options: {
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
@@ -337,9 +339,13 @@ describe("module route handler adaptation", () => {
|
||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||
expect(capturedRequest).toBeInstanceOf(Request);
|
||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
||||
// or configuration reaches a module handler.
|
||||
// Exactly config, moduleId, params, principal, services — and nothing else. `config` is this
|
||||
// module's own frozen configuration snapshot, added deliberately by ADR 0029; a Hono Context,
|
||||
// the authorization store or evaluator, raw PostgreSQL, platform configuration, and another
|
||||
// module's configuration all remain unreachable, which is what this exact-shape assertion
|
||||
// exists to keep true.
|
||||
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
||||
"config",
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
@@ -356,6 +362,10 @@ describe("module route handler adaptation", () => {
|
||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
|
||||
// This module declared no configuration, so its snapshot is empty rather than absent — module
|
||||
// code never has to branch on whether the platform supplied one.
|
||||
expect(capturedContext?.config.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives static and root routes a frozen, empty params object", async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -74,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -168,6 +169,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
/**
|
||||
* The whole chain, with nothing between the halves faked:
|
||||
@@ -102,7 +103,7 @@ async function createFixtureModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -221,6 +222,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
@@ -79,7 +80,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -177,6 +178,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
|
||||
@@ -103,7 +104,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 4,
|
||||
manifestVersion: 5,
|
||||
name: `${fixture.id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -177,6 +178,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user