feat: Module Package v1 carries the module's own license (ADR 0051)
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s

Every Module Package v1 now declares the module's license and carries its
text. packageFormat stays 1; pre-ADR-0051 metadata fails closed.

- Contracts: ekklesia.package.json requires an SPDX `license` expression,
  validated with spdx-expression-parse, with UNLICENSED and non-expressions
  refused. The `files` list must hold exactly one non-empty root LICENSE.
  The package contracts move to the @ekklesia/contracts/module-package
  subpath, so the SPDX parser and its data (spdx-exceptions and
  spdx-license-ids ship no licence files) never reach a module bundle
  through the SDK's re-export of the contracts root.
- Packager: reads the source package.json's string SPDX declaration and
  never invents one. It requires an explicit --license-file (a non-empty
  regular file, no symlink, no parent search, no download) and copies its
  bytes unchanged to LICENSE, listed with size and SHA-256 and covered by
  the archive digest. THIRD_PARTY_NOTICES stays third-party only, and
  bundled Ekklesia code stays in `incorporates`.
- Verifier: rejects missing or invalid license metadata, and a missing,
  unlisted, duplicated, or corrupted LICENSE, through the schema and the
  existing strict inventory.
- Template: packaged as AGPL-3.0-or-later with the repository's canonical
  LICENSE, passed explicitly (Dockerfile, smoke, docs). Packing it twice
  gives byte-identical archives.

Started by Codex; reviewed and completed in Claude Code (empty license-text
refusal, schema non-empty LICENSE, symlink and empty-file tests).

ADR 0051 is Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 18:51:03 -05:00
co-authored by Claude Opus 5.5
parent 30f61094e6
commit 85fae9e07e
22 changed files with 400 additions and 40 deletions
@@ -6,15 +6,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import {
isVersionCompatible,
type ModuleManifest,
safeParseModuleManifest,
} from "@ekklesia/contracts";
import {
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
type ModuleManifest,
type ModulePackageMetadata,
safeParseModuleManifest,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts";
} from "@ekklesia/contracts/module-package";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import {
+84 -1
View File
@@ -91,7 +91,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
expect(result).toEqual({
archiveSha256: digest,
fileCount: 8,
fileCount: 9,
moduleId: "example",
target: "any",
version: "0.1.0",
@@ -104,6 +104,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
await verifyModulePackage({ archivePath, extractTo: target, platform });
const names = (await readdir(target, { recursive: true })).sort();
expect(names).toContain("ekklesia.package.json");
expect(names).toContain("LICENSE");
expect(names).toContain("dist/server/index.js");
expect((await stat(target)).mode & 0o7777).toBe(0o755);
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
@@ -153,6 +154,88 @@ describe("Module Package v1 verification (ADR 0049)", () => {
);
});
it.each([
["a missing license declaration", undefined],
["an empty license declaration", ""],
["UNLICENSED", "UNLICENSED"],
["a malformed SPDX expression", "MIT OR"],
])("fails package metadata with %s", async (_label, license) => {
await rejection(
{
metadata: (metadata) => {
const changed: Record<string, unknown> = { ...metadata, license };
if (license === undefined) {
delete changed.license;
}
return changed;
},
},
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("fails closed on pre-ADR-0051 metadata", async () => {
await rejection(
{
metadata: (metadata) => {
const old: Record<string, unknown> = { ...metadata };
delete old.license;
return old;
},
},
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
await rejection(
{ tamper: (files) => void files.delete("LICENSE") },
"MODULE_PACKAGE_FILE_MISSING",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
}),
},
"MODULE_PACKAGE_METADATA_INVALID",
);
await rejection(
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
await rejection(
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it("checks the listed LICENSE bytes, size, and digest", async () => {
await rejection(
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
"MODULE_PACKAGE_FILE_MISMATCH",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
),
}),
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("rejects a duplicate LICENSE archive entry", async () => {
await rejection(
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
"MODULE_PACKAGE_LAYOUT_INVALID",
);
});
it("fails when the manifest and the package disagree about the version", async () => {
await rejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
@@ -162,6 +162,7 @@ export async function modulePackageFixture(
),
);
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
files.set("LICENSE", Buffer.from("Example module license text.\n"));
const listed = [...files.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
@@ -169,6 +170,7 @@ export async function modulePackageFixture(
const baseMetadata: Record<string, unknown> = {
packageFormat: 1,
module: { id, version },
license: "MIT",
target: "any",
runtime: { node: ">=20" },
provenance: {