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
+61 -2
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { readFile, symlink, writeFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -21,6 +21,7 @@ async function pack(options: FixtureModuleOptions = {}, overrides: Record<string
const moduleDirectory = await fixtureModule(options);
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
const result = await packModule({
licenseFile: path.join(moduleDirectory, "LICENSE"),
moduleDirectory,
nodeRange: ">=24.11.0 <25",
outDirectory,
@@ -61,6 +62,7 @@ describe("packModule: what a package contains (ADR 0049)", () => {
const { archive } = await pack({ withSchema: true });
const files = [...fileEntries(archive).keys()].sort();
expect(files).toEqual([
"example/LICENSE",
"example/THIRD_PARTY_NOTICES",
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
"example/dist/schema/index.js",
@@ -81,6 +83,62 @@ describe("packModule: what a package contains (ADR 0049)", () => {
);
});
it("copies the explicit license bytes unchanged and records their source SPDX expression", async () => {
const licenseText = Buffer.from([0x4d, 0x49, 0x54, 0x0d, 0x0a, 0x00, 0xff]);
const { archive } = await pack({ license: "MIT OR Apache-2.0", licenseText });
const files = fileEntries(archive);
const packaged = files.get("example/LICENSE");
const metadata = JSON.parse(
files.get("example/ekklesia.package.json")?.toString("utf8") ?? "{}",
) as {
license?: string;
files?: { path: string; size: number; sha256: string }[];
};
expect(metadata.license).toBe("MIT OR Apache-2.0");
expect(packaged?.equals(licenseText)).toBe(true);
expect(metadata.files?.filter((file) => file.path === "LICENSE")).toEqual([
{
path: "LICENSE",
size: licenseText.length,
sha256: createHash("sha256").update(licenseText).digest("hex"),
},
]);
});
it.each([
["a missing declaration", undefined],
["an empty declaration", ""],
["UNLICENSED", "UNLICENSED"],
["legacy object metadata", { type: "MIT", url: "https://example.invalid/license" }],
["a malformed SPDX expression", "MIT OR"],
])("refuses %s", async (_label, license) => {
await packRejection({ license }, "MODULE_PACK_MODULE_LICENSE_INVALID");
});
it("requires a readable regular license-text input", async () => {
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: undefined,
});
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: path.join(await scratchDirectory("ekklesia-missing-license-"), "missing"),
});
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: await scratchDirectory("ekklesia-license-directory-"),
});
const emptyDirectory = await scratchDirectory("ekklesia-empty-license-");
await writeFile(path.join(emptyDirectory, "LICENSE"), "");
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: path.join(emptyDirectory, "LICENSE"),
});
const linkDirectory = await scratchDirectory("ekklesia-linked-license-");
await writeFile(path.join(linkDirectory, "real"), "MIT License\n");
await symlink(path.join(linkDirectory, "real"), path.join(linkDirectory, "LICENSE"));
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: path.join(linkDirectory, "LICENSE"),
});
});
it("generates the runtime package.json instead of copying the development one", async () => {
const { archive } = await pack();
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
@@ -420,11 +478,12 @@ describe("packModule: THIRD_PARTY_NOTICES", () => {
describe("packModule: deterministic archives", () => {
it("produces byte-identical archives and digests for identical inputs", async () => {
const moduleDirectory = await fixtureModule({ withSchema: true });
const runs = [];
for (let run = 0; run < 2; run += 1) {
const moduleDirectory = await fixtureModule({ withSchema: true });
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
const result = await packModule({
licenseFile: path.join(moduleDirectory, "LICENSE"),
moduleDirectory,
nodeRange: ">=24.11.0 <25",
outDirectory,