`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import type {
|
|
ModuleBlockedReason,
|
|
ModuleBlockingDependency,
|
|
ModuleRuntimeState,
|
|
ModuleSecretSource,
|
|
} from "@ekklesia/contracts";
|
|
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
|
import {
|
|
type ModuleConfigurationStore,
|
|
type ModuleLifecycleSnapshot,
|
|
type ModuleStorageAvailability,
|
|
type ModuleLifecycleStore,
|
|
ModuleLifecycleStoreError,
|
|
type ModuleLifecycleView,
|
|
resolveModuleLifecycleView,
|
|
} 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 of it — the same rule ADR 0025 established for
|
|
* `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under
|
|
* `/api/v1/modules/<module-id>/<local path>`, and `moduleRoutePathSchema` accepts `enabled` as an
|
|
* ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land
|
|
* on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle
|
|
* 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 whether modules run. A sibling prefix
|
|
* cannot collide.
|
|
*/
|
|
export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle";
|
|
const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`;
|
|
|
|
/**
|
|
* One module as an operator sees it. Two clearly separated axes, deliberately never merged into a
|
|
* single status word.
|
|
*
|
|
* `configured` is the decision that survives restarts. `runtime` is what *this process* did. They
|
|
* can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and
|
|
* `restartRequired` names that disagreement instead of hiding it behind a status that would be a
|
|
* lie in one direction or the other.
|
|
*
|
|
* Nothing here carries a filesystem path, a package name, a manifest path, a directory, an
|
|
* entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and
|
|
* a fixed reason code from a closed set — all of them values the platform itself produced and
|
|
* validated.
|
|
*/
|
|
export interface ModuleLifecycleModuleView {
|
|
readonly id: string;
|
|
readonly version: string;
|
|
readonly configured: {
|
|
readonly enabled: boolean;
|
|
/** `false` means nobody has decided and the platform default (enabled) applies. */
|
|
readonly explicit: boolean;
|
|
};
|
|
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;
|
|
/** Present for a storage reason (ADR 0030): the logical bucket the module declared, never an
|
|
* endpoint, a credential, or the physical bucket the host maps it to. */
|
|
readonly storageBucketId?: string;
|
|
};
|
|
readonly restartRequired: boolean;
|
|
}
|
|
|
|
export interface ModuleLifecycleResponse {
|
|
readonly modules: readonly ModuleLifecycleModuleView[];
|
|
}
|
|
|
|
export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions {
|
|
/** The boot snapshot: the discovered inventory this process started from, the eligibility it
|
|
* resolved, and the modules the runtime reports it actually started. Captured once at startup and
|
|
* never recomputed — this endpoint describes the platform that is running, not one that would run
|
|
* 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;
|
|
};
|
|
/** The other axis that decides eligibility (ADR 0030). A boolean, never a probe: whether storage
|
|
* is *configured* decides eligibility, while whether it is *reachable right now* never does. */
|
|
readonly storage: ModuleStorageAvailability;
|
|
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, exactly as
|
|
* `dashboard-routes.ts` does. */
|
|
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
|
if (error instanceof ModuleLifecycleStoreError) {
|
|
if (
|
|
error.code === "MODULE_LIFECYCLE_READ_FAILED" ||
|
|
error.code === "MODULE_LIFECYCLE_WRITE_FAILED"
|
|
) {
|
|
return sanitizedFailure(c, 503, error.code);
|
|
}
|
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
|
}
|
|
return sanitizedFailure(
|
|
c,
|
|
503,
|
|
fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED",
|
|
);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
const ENABLED_BODY_KEYS = new Set(["enabled"]);
|
|
|
|
/**
|
|
* Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the
|
|
* whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a
|
|
* future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors
|
|
* `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly.
|
|
*/
|
|
function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined {
|
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) {
|
|
return undefined;
|
|
}
|
|
return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined;
|
|
}
|
|
|
|
async function readJsonBody(c: Context): Promise<unknown> {
|
|
try {
|
|
return (await c.req.json()) as unknown;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
|
return {
|
|
configured: { enabled: view.configured.enabled, explicit: view.configured.explicit },
|
|
id: view.moduleId,
|
|
restartRequired: view.restartRequired,
|
|
runtime: {
|
|
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 }),
|
|
...(view.runtime.storageBucketId === undefined
|
|
? {}
|
|
: { storageBucketId: view.runtime.storageBucketId }),
|
|
},
|
|
version: view.version,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The platform's operational lifecycle resource: which installed modules are enabled, what this
|
|
* process actually did with each of them, and the narrow, structured API for changing that.
|
|
*
|
|
* ## Enablement is host configuration, and structurally cannot be anything else
|
|
*
|
|
* A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a
|
|
* permission declaration, or a lifecycle hook can influence a value stored here, and a module is
|
|
* never told it is enabled — its host simply loads and starts it, or does not. That is what keeps
|
|
* the same module implementation reusable under a future standalone host, whose loading decisions
|
|
* are its own (ADR 0028).
|
|
*
|
|
* ## Changing configuration does not change the running process
|
|
*
|
|
* `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is
|
|
* no hot module installation or removal in this platform (ADR 0015, unchanged). The response
|
|
* therefore reports the new configuration *and* the unchanged runtime observation *and*
|
|
* `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a
|
|
* button feel immediate would trade a real guarantee — that a module's ESM evaluation happens
|
|
* exactly once, and can never be rolled back — for a cosmetic one.
|
|
*
|
|
* ## Only a module this process can see may be configured
|
|
*
|
|
* `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard
|
|
* placement, an enablement record is operational configuration, and accepting one for an arbitrary
|
|
* identifier would make this an unbounded write surface for a delegable permission. *Existing*
|
|
* records for modules that are no longer deployed are still retained and simply ignored, so
|
|
* redeploying a module restores the decision (see `resolveModuleEligibility`).
|
|
*
|
|
* ## Reading is administrative too
|
|
*
|
|
* Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place
|
|
* a disabled module, a blocked module, and the required-dependency edges between them are visible;
|
|
* `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require
|
|
* an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every
|
|
* other Core mutation.
|
|
*/
|
|
export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono {
|
|
const routes = new Hono();
|
|
const requireManage = requirePermission(
|
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
|
);
|
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
|
const installedModuleIds = new Set(
|
|
options.snapshot.discoveredModules.map((module) => module.manifest.id),
|
|
);
|
|
|
|
routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => {
|
|
c.header("Cache-Control", "no-store");
|
|
// Not per-principal in content, but it is cookie-authorized, so it varies for the same reason
|
|
// every other authorized Core read does.
|
|
c.header("Vary", "Origin, Cookie");
|
|
try {
|
|
const [configuration, settings] = await Promise.all([
|
|
options.store.readConfiguration(),
|
|
options.configuration.store.readSettings(),
|
|
]);
|
|
const modules = resolveModuleLifecycleView(
|
|
options.snapshot,
|
|
configuration,
|
|
{ secrets: options.configuration.secrets, settings },
|
|
options.storage,
|
|
).map(present);
|
|
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
|
} catch (error) {
|
|
return storeFailure(c, error, "read");
|
|
}
|
|
});
|
|
|
|
routes.put(
|
|
MODULE_LIFECYCLE_ENABLED_PATH,
|
|
requireOrigin,
|
|
mutationBodyLimit,
|
|
requireManage,
|
|
async (c) => {
|
|
c.header("Cache-Control", "no-store");
|
|
const moduleId = c.req.param("moduleId");
|
|
const body = parseEnabledBody(await readJsonBody(c));
|
|
if (!body) {
|
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
|
}
|
|
if (!installedModuleIds.has(moduleId)) {
|
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
|
}
|
|
try {
|
|
await options.store.setEnabled({ enabled: body.enabled, moduleId });
|
|
// 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, settings] = await Promise.all([
|
|
options.store.readConfiguration(),
|
|
options.configuration.store.readSettings(),
|
|
]);
|
|
const view = resolveModuleLifecycleView(
|
|
options.snapshot,
|
|
configuration,
|
|
{ secrets: options.configuration.secrets, settings },
|
|
options.storage,
|
|
).find((entry) => entry.moduleId === moduleId);
|
|
if (!view) {
|
|
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
|
}
|
|
return c.json(present(view), 200);
|
|
} catch (error) {
|
|
return storeFailure(c, error, "write");
|
|
}
|
|
},
|
|
);
|
|
|
|
return routes;
|
|
}
|