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
+36 -3
View File
@@ -3,17 +3,18 @@ import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "no
import { isBuiltin } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type ModuleManifest, safeParseModuleManifest } from "@ekklesia/contracts";
import {
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_LICENSE_FILENAME,
MODULE_PACKAGE_METADATA_FILENAME,
MODULE_PACKAGE_NOTICES_FILENAME,
type ModuleManifest,
modulePackageArchiveFileName,
modulePackageRuntimePackage,
safeParseModuleManifest,
safeParseModulePackageLicenseExpression,
safeParseModulePackageMetadata,
} from "@ekklesia/contracts";
} from "@ekklesia/contracts/module-package";
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, validRange } from "semver";
@@ -40,6 +41,8 @@ export type ModulePackErrorCode =
| "MODULE_PACK_IDENTITY_MISMATCH"
| "MODULE_PACK_INCORPORATED_CONFLICT"
| "MODULE_PACK_LAYOUT_CONFLICT"
| "MODULE_PACK_MODULE_LICENSE_FILE_INVALID"
| "MODULE_PACK_MODULE_LICENSE_INVALID"
| "MODULE_PACK_LICENCE_TEXT_MISSING"
| "MODULE_PACK_LICENCE_UNKNOWN"
| "MODULE_PACK_MANIFEST_INVALID"
@@ -71,6 +74,10 @@ const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
MODULE_PACK_INCORPORATED_CONFLICT:
"The package bundles more than one version of an Ekklesia package.",
MODULE_PACK_LAYOUT_CONFLICT: "Two package files would have the same path.",
MODULE_PACK_MODULE_LICENSE_FILE_INVALID:
"The module license text must be supplied as an explicit, non-empty, readable regular file.",
MODULE_PACK_MODULE_LICENSE_INVALID:
"The source package.json must declare a valid SPDX license expression as a string.",
MODULE_PACK_LICENCE_TEXT_MISSING:
"A bundled package ships no licence text, and its licence has no complete standard text to supply.",
MODULE_PACK_LICENCE_UNKNOWN: "A bundled package declares no licence that permits redistribution.",
@@ -115,6 +122,8 @@ export class ModulePackError extends Error {
export interface PackModuleOptions {
/** A built module directory: its manifest, package.json, and compiled entrypoints. */
readonly moduleDirectory: string;
/** Explicit license-text input. Its bytes are copied unchanged to the package root as LICENSE. */
readonly licenseFile: string;
/** `any` only, in this release. */
readonly target: string;
readonly outDirectory: string;
@@ -377,6 +386,28 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
) {
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH");
}
const parsedLicense = safeParseModulePackageLicenseExpression(developmentPackage.license);
if (!parsedLicense.success) {
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_INVALID");
}
const license = parsedLicense.data;
let licenseBytes: Buffer;
try {
if (
typeof options.licenseFile !== "string" ||
options.licenseFile === "" ||
!(await lstat(path.resolve(options.licenseFile))).isFile()
) {
throw new Error("not a regular file");
}
licenseBytes = await readFile(path.resolve(options.licenseFile));
if (licenseBytes.length === 0) {
// An empty file would satisfy the inventory while carrying no license material at all.
throw new Error("empty");
}
} catch {
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_FILE_INVALID");
}
const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
if (validRange(nodeRange) === null) {
@@ -493,6 +524,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
}
}
add("ekklesia.module.json", manifestBytes);
add(MODULE_PACKAGE_LICENSE_FILENAME, licenseBytes);
add(
"package.json",
Buffer.from(
@@ -522,6 +554,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
const metadata = {
packageFormat: MODULE_PACKAGE_FORMAT,
module: { id: manifest.id, version: manifest.version },
license,
target: MODULE_PACKAGE_ANY_TARGET,
runtime: { node: nodeRange },
provenance: {