Files
Ekklesia/packages/contracts/tests/module-manifest.test.ts
T
ekklesiaandClaude Opus 5 b9ca0d560d
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
feat: add host-provided module object storage
`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]>
2026-08-23 08:45:53 -05:00

500 lines
18 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isVersionCompatible,
isModuleConfigurationBlockedReason,
MODULE_BLOCKED_REASONS,
MODULE_CONFIGURATION_BLOCKED_REASONS,
MODULE_DEPENDENCY_KINDS,
MODULE_STORAGE_BLOCKED_REASON,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
packageRelativePathSchema,
parseModuleManifest,
parsePermissionId,
safeParseModuleManifest,
} from "../src/index.js";
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 5,
id: "calendar",
name: "Calendar",
version: "0.1.0",
compatibility: {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
},
],
storage: {
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
},
entrypoints: {
server: "./dist/server/index.js",
},
};
}
describe("module manifest contract", () => {
it("normalizes a valid manifest with safe defaults", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.id).toBe("calendar");
expect(manifest.dependencies).toEqual({
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
});
expect(manifest.storage.buckets).toEqual([
{ id: "assets", description: "Calendar-owned event assets." },
]);
});
it("creates isolated default collections for every parsed manifest", () => {
const input = validManifest();
delete input.storage;
delete input.permissions;
const first = parseModuleManifest(input);
const second = parseModuleManifest(input);
first.dependencies.modules.required.people = "^0.1.0";
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
first.storage.buckets.push({ id: "exports" });
first.permissions.push({
id: parsePermissionId("calendar.events.write"),
baseline: "restricted",
description: "Write calendar events.",
});
expect(second.dependencies.modules.required).toEqual({});
expect(second.dependencies.capabilities.required).toEqual({});
expect(second.storage.buckets).toEqual([]);
expect(second.permissions).toEqual([]);
});
it("rejects unknown and presentation-oriented manifest properties", () => {
const manifest = { ...validManifest(), renderers: ["calendar-month"] };
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
});
it("requires module-owned capability and permission namespaces", () => {
const manifest = {
...validManifest(),
capabilities: [{ id: "media.items", version: "1.0.0" }],
permissions: [
{ id: "media.items.read", baseline: "restricted", description: "Read media items." },
],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]),
);
}
});
it("rejects duplicate declarations and dependency overlap", () => {
const manifest = {
...validManifest(),
dependencies: {
modules: {
required: { people: "^1.0.0" },
optional: { people: "^1.0.0" },
},
capabilities: {
required: { "people.records": "^1.0.0" },
optional: { "people.records": "^1.0.0" },
},
},
capabilities: [
{ id: "calendar.events", version: "2.3.0" },
{ id: "calendar.events", version: "2.4.0" },
],
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toHaveLength(4);
}
});
it("rejects a duplicate permission id at its exact issue path", () => {
const manifest = {
...validManifest(),
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
},
{
id: "calendar.events.read",
baseline: "authenticated",
description: "Read calendar events, again.",
},
],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"permissions.1.id",
);
}
});
it("rejects module and capability self-dependencies", () => {
const manifest = {
...validManifest(),
dependencies: {
modules: { required: { calendar: "^0.1.0" } },
capabilities: { required: { "calendar.events": "^2.0.0" } },
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("versions capabilities independently from the module release", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.version).toBe("0.1.0");
expect(manifest.capabilities).toEqual([{ id: "calendar.events", version: "2.3.0" }]);
});
it("requires exact capability versions and dependency ranges", () => {
const manifest = {
...validManifest(),
capabilities: [{ id: "calendar.events", version: "^2.0.0" }],
dependencies: {
capabilities: { required: { "people.records": "not-a-range" } },
},
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining([
"capabilities.0.version",
"dependencies.capabilities.required.people.records",
]),
);
}
});
it("rejects obsolete manifest versions instead of guessing a migration", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
});
it("rejects a version 2 manifest rather than silently interpreting it as a later version", () => {
// manifestVersion 2's own permissions shape (a bare string array) is also what every later
// version must reject — this manifest is invalid twice over, deliberately, since a real
// version 2 manifest is exactly this shape.
const version2Manifest = {
...validManifest(),
manifestVersion: 2,
permissions: ["calendar.events.read"],
};
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
});
it("rejects a version 3 manifest rather than silently interpreting it as a later version", () => {
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
});
it("rejects a version 4 manifest rather than silently interpreting it as version 5", () => {
// A manifest declaring no configuration is otherwise byte-identical between version 4 and
// version 5: only the version literal separates them, and it is not reinterpreted (ADR 0029).
const version4Manifest = { ...validManifest(), manifestVersion: 4 };
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(5);
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
.success,
).toBe(true);
});
it("rejects a bare permission-id string array instead of complete declarations", () => {
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("defaults to an empty permission list when omitted", () => {
const input = validManifest();
delete input.permissions;
const manifest = parseModuleManifest(input);
expect(manifest.permissions).toEqual([]);
});
it.each([
"public",
"authenticated",
"restricted",
] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => {
const manifest = parseModuleManifest({
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }],
});
expect(manifest.permissions).toEqual([
{ id: "calendar.events.read", baseline, description: "Read events." },
]);
});
it("rejects a permission declaration with a missing baseline", () => {
const manifest = {
...validManifest(),
permissions: [{ id: "calendar.events.read", description: "Read events." }],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with an invalid baseline", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "calendar.events.read", baseline: "superuser", description: "Read events." },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with a missing or blank description", () => {
const missingDescription = {
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline: "restricted" }],
};
const blankDescription = {
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }],
};
expect(safeParseModuleManifest(missingDescription).success).toBe(false);
expect(safeParseModuleManifest(blankDescription).success).toBe(false);
});
it("rejects a permission declaration with an oversized description", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with an unknown property", () => {
const manifest = {
...validManifest(),
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read events.",
scope: "calendar-event:123",
},
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a malformed permission id", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
"../outside.ts",
"/absolute.ts",
"file:///server.ts",
"./src/../server.ts",
"./src\\server.ts",
"./src/server.ts?raw",
])("rejects unsafe package entrypoint %s", (entrypoint) => {
expect(packageRelativePathSchema.safeParse(entrypoint).success).toBe(false);
});
it("allows package directories whose names merely begin with two dots", () => {
expect(packageRelativePathSchema.safeParse("./..data/server.ts").success).toBe(true);
});
it("uses full semantic-version compatibility ranges", () => {
expect(isVersionCompatible("0.1.5", "^0.1.0")).toBe(true);
expect(isVersionCompatible("0.2.0", "^0.1.0")).toBe(false);
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
});
it("publishes exactly the operational lifecycle vocabulary the platform implements", () => {
// Every name here is produced by real code and observable through the lifecycle read model
// (ADR 0028). The nine-stage `MODULE_LIFECYCLE_STAGES` vocabulary this replaced named states
// nothing ever entered, including a `failed` state that startup atomicity makes unobservable —
// asserting the exact sets is what keeps this contract from drifting back into aspiration.
expect(MODULE_RUNTIME_STATES).toEqual(["running", "disabled", "blocked"]);
expect(MODULE_RUNTIME_STATES).not.toContain("failed");
expect(MODULE_BLOCKED_REASONS).toEqual([
"required_dependency_disabled",
"required_dependency_blocked",
"configuration_missing",
"configuration_invalid",
"secret_missing",
"storage_unavailable",
]);
// Storage adds exactly one reason, and only one: a *binding* that cannot be supplied. A
// configured provider that is temporarily unreachable is deliberately not a lifecycle state
// (ADR 0030), so there is no `storage_unreachable` here and there must never be.
expect(MODULE_BLOCKED_REASONS).not.toContain("storage_unreachable");
expect(MODULE_STORAGE_BLOCKED_REASON).toBe("storage_unavailable");
// The three configuration reasons are a subset, and each corresponds to a real implemented
// check with a distinct operator fix (ADR 0029) — the only justification for a separate code.
expect(MODULE_CONFIGURATION_BLOCKED_REASONS).toEqual([
"configuration_missing",
"configuration_invalid",
"secret_missing",
]);
for (const reason of MODULE_CONFIGURATION_BLOCKED_REASONS) {
expect(isModuleConfigurationBlockedReason(reason)).toBe(true);
}
expect(isModuleConfigurationBlockedReason("required_dependency_disabled")).toBe(false);
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
});
describe("module-owned UI entrypoint (ADR 0021)", () => {
it("accepts a backend-only module that declares no UI at all", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it("accepts a declared compiled UI entrypoint alongside the server entrypoint", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
// Declaring UI never alters the backend contract: a product module is one module, not two.
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it.each([
["../outside/ui.js", "escapes the package boundary"],
["/absolute/ui.js", "is an absolute path"],
["./dist/../../ui.js", "traverses out through a parent segment"],
["file:///dist/ui/index.js", "is a URL rather than a package-relative path"],
["./dist\\ui\\index.js", "uses backslash separators"],
["./dist/ui/index.js?raw", "carries a query string"],
["", "is empty"],
])("rejects a UI entrypoint that %s", (ui) => {
const manifest = {
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui },
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
42,
null,
true,
["./dist/ui/index.js"],
{ entry: "./dist/ui/index.js" },
])("rejects a non-string UI entrypoint %s", (ui) => {
const manifest = {
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui },
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("still rejects unknown entrypoint keys, so UI is the only new one", () => {
for (const unknownEntrypoint of ["dashboard", "surface", "client", "web", "renderers"]) {
const manifest = {
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
[unknownEntrypoint]: "./dist/ui/index.js",
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
}
});
it("declares no grouping, folder, parent, or menu field for a module to own", () => {
// Dashboard organization is presentation configuration, never module architecture
// (ADR 0021). The manifest must offer no place to encode a hierarchy.
for (const presentationField of [
"group",
"folder",
"parent",
"menu",
"navigation",
"icon",
"order",
]) {
const manifest = { ...validManifest(), [presentationField]: "media" };
expect(safeParseModuleManifest(manifest).success).toBe(false);
}
});
it("keeps the module dependency graph flat: UI adds no parent/child relationship", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
// A module that ships UI still expresses relationships only through genuine module and
// capability dependencies — the same two mechanisms a backend-only module uses.
expect(Object.keys(manifest.dependencies.modules.required)).toEqual([]);
expect(Object.keys(manifest.dependencies.modules.optional)).toEqual([]);
expect(Object.keys(manifest)).not.toContain("children");
});
it("still supports a genuine module dependency for a module that ships UI", () => {
const manifest = parseModuleManifest({
...validManifest(),
dependencies: {
modules: { required: { people: "^0.1.0" } },
capabilities: { required: { "people.records": "^2.0.0" } },
},
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
expect(manifest.dependencies.modules.required).toEqual({ people: "^0.1.0" });
expect(manifest.dependencies.capabilities.required).toEqual({ "people.records": "^2.0.0" });
});
});
});