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
View File
@@ -10,6 +10,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./module-package": {
"types": "./dist/module-package.d.ts",
"import": "./dist/module-package.js"
}
},
"scripts": {
@@ -22,12 +26,14 @@
"dependencies": {
"drizzle-orm": "0.45.2",
"semver": "7.8.5",
"spdx-expression-parse": "5.0.0",
"zod": "4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@types/semver": "7.7.1",
"@types/spdx-expression-parse": "4.0.0",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
-1
View File
@@ -9,7 +9,6 @@ export * from "./module-http.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-migration.js";
export * from "./module-package.js";
export * from "./module-realtime.js";
export * from "./module-runtime.js";
export * from "./module-schema.js";
+36
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import parseSpdxExpression from "spdx-expression-parse";
import { moduleIdSchema } from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
@@ -11,11 +12,30 @@ import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js
*/
export const MODULE_PACKAGE_FORMAT = 1;
export const MODULE_PACKAGE_LICENSE_FILENAME = "LICENSE";
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
/** The portable target: a package with no native or platform-specific file. */
export const MODULE_PACKAGE_ANY_TARGET = "any";
/** The module publisher's package-level license declaration (ADR 0051). This validates SPDX syntax,
* not whether Ekklesia approves, recommends, or considers the license legally compatible. */
export const modulePackageLicenseExpressionSchema = z
.string()
.min(1)
.max(1024)
.superRefine((value, context) => {
if (value === "UNLICENSED") {
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
return;
}
try {
parseSpdxExpression(value);
} catch {
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
}
});
/**
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
* vocabulary. On Linux the C library is required, because a native addon built against one does
@@ -104,6 +124,8 @@ export const modulePackageMetadataSchema = z
.strictObject({
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
/** The module's authoritative package-release license declaration (ADR 0051). */
license: modulePackageLicenseExpressionSchema,
target: modulePackageTargetSchema,
runtime: modulePackageRuntimeSchema,
provenance: z.strictObject({
@@ -124,6 +146,16 @@ export const modulePackageMetadataSchema = z
files: z.array(modulePackageFileSchema),
})
.superRefine((metadata, context) => {
const licenseFiles = metadata.files.filter(
(file) => file.path === MODULE_PACKAGE_LICENSE_FILENAME,
);
if (licenseFiles.length !== 1 || licenseFiles[0]?.size === 0) {
context.addIssue({
code: "custom",
message: "The payload must contain exactly one non-empty root LICENSE file.",
path: ["files"],
});
}
if (
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
(metadata.runtime.nodeApi !== undefined ||
@@ -157,6 +189,10 @@ export function safeParseModulePackageMetadata(input: unknown) {
return modulePackageMetadataSchema.safeParse(input);
}
export function safeParseModulePackageLicenseExpression(input: unknown) {
return modulePackageLicenseExpressionSchema.safeParse(input);
}
export function safeParseModulePackageTarget(input: unknown) {
return modulePackageTargetSchema.safeParse(input);
}
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import {
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_LICENSE_FILENAME,
MODULE_PACKAGE_METADATA_FILENAME,
modulePackageArchiveFileName,
modulePackageRuntimePackage,
safeParseModulePackageLicenseExpression,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
safeParseModulePackageTarget,
} from "../src/index.js";
} from "../src/module-package.js";
const digest = "a".repeat(64);
@@ -15,6 +17,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
return {
packageFormat: 1,
module: { id: "example", version: "0.1.0" },
license: "AGPL-3.0-or-later",
target: "any",
runtime: { node: ">=24.11.0 <25" },
provenance: {
@@ -24,6 +27,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
files: [
{ path: "LICENSE", size: 9, sha256: digest },
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
{ path: "dist/server/index.js", size: 20, sha256: digest },
{ path: "ekklesia.module.json", size: 30, sha256: digest },
@@ -36,12 +40,46 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
it("names the format, its metadata file, and the recommended archive file name", () => {
expect(MODULE_PACKAGE_FORMAT).toBe(1);
expect(MODULE_PACKAGE_LICENSE_FILENAME).toBe("LICENSE");
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
"example-0.1.0-any.tar.gz",
);
});
it("accepts SPDX license expressions and rejects non-expressions without a policy allowlist", () => {
for (const expression of [
"AGPL-3.0-or-later",
"MIT OR Apache-2.0",
"(MIT OR Apache-2.0) AND BSD-3-Clause",
]) {
expect(safeParseModulePackageLicenseExpression(expression).success, expression).toBe(true);
}
for (const expression of ["", "UNLICENSED", "MIT OR", "not a license", {}]) {
expect(safeParseModulePackageLicenseExpression(expression).success, String(expression)).toBe(
false,
);
}
});
it("requires the package license declaration and canonical LICENSE payload entry", () => {
const withoutLicense = metadata();
delete withoutLicense.license;
expect(safeParseModulePackageMetadata(withoutLicense).success).toBe(false);
const withoutLicenseFile = metadata();
withoutLicenseFile.files = (withoutLicenseFile.files as { path: string }[]).filter(
(file) => file.path !== "LICENSE",
);
expect(safeParseModulePackageMetadata(withoutLicenseFile).success).toBe(false);
const emptyLicenseFile = metadata();
emptyLicenseFile.files = (emptyLicenseFile.files as { path: string; size: number }[]).map(
(file) => (file.path === "LICENSE" ? { ...file, size: 0 } : file),
);
expect(safeParseModulePackageMetadata(emptyLicenseFile).success).toBe(false);
});
it("accepts a complete any-target package description", () => {
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
});
@@ -97,7 +135,9 @@ describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
).toBe(false);
// Byte-wise, not locale order: upper case sorts before lower case.
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success,
safeParseModulePackageMetadata(
metadata({ files: [entry("LICENSE"), entry("THIRD"), entry("dist")] }),
).success,
).toBe(true);
});