Files
ekklesiaandClaude Opus 5.5 85fae9e07e
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
feat: Module Package v1 carries the module's own license (ADR 0051)
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]>
2026-10-07 18:51:03 -05:00

243 lines
9.7 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { verifyModulePackage } from "@ekklesia/core/modules";
import { afterEach, describe, expect, it } from "vitest";
import { packModule } from "../src/index.js";
import { cleanupFixtures, SOURCE_REVISION, scratchDirectory } from "./support/fixture-module.js";
import { fileEntries } from "./support/tar.js";
/**
* The canonical template module, packaged end to end (ADR 0049): the real built template, the real
* packager, and Core's real verifier. Turbo builds the template and the verifier before this suite
* runs (both are development dependencies of this package), and runs the template's own tests, which
* rebuild its UI in place, before this one (turbo.json).
*/
const execFileAsync = promisify(execFile);
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repositoryRoot = path.resolve(packageRoot, "..", "..");
afterEach(cleanupFixtures);
async function templateDirectory(): Promise<string> {
return realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-template"));
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
async function platform(databaseRuntimeVersion?: string) {
const core = await readJson(path.join(repositoryRoot, "core", "package.json"));
return {
databaseRuntime: {
"drizzle-orm":
databaseRuntimeVersion ??
(core.dependencies as Record<string, string>)["drizzle-orm"] ??
"",
},
moduleSdkVersion: String(
(await readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json"))).version,
),
platformVersion: String((await readJson(path.join(repositoryRoot, "package.json"))).version),
};
}
async function fingerprint(file: string): Promise<string> {
try {
return createHash("sha256")
.update(await readFile(file))
.digest("hex");
} catch {
return "absent";
}
}
async function treeListing(directory: string): Promise<readonly string[]> {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => !path.join(entry.parentPath, entry.name).includes(`${path.sep}node_modules`))
.map((entry) => path.relative(directory, path.join(entry.parentPath, entry.name)))
.sort();
}
async function packTemplate() {
const outDirectory = await scratchDirectory("ekklesia-template-package-");
const result = await packModule({
licenseFile: path.join(repositoryRoot, "LICENSE"),
moduleDirectory: await templateDirectory(),
outDirectory,
sourceRevision: SOURCE_REVISION,
target: "any",
});
return { archive: await readFile(result.archivePath), result };
}
describe("the module template as a Module Package v1", () => {
it("packs deterministically, without touching the workspace it was built in", async () => {
const template = await templateDirectory();
const watched = [
path.join(repositoryRoot, "node_modules", ".pnpm-workspace-state-v1.json"),
path.join(repositoryRoot, "node_modules", ".modules.yaml"),
path.join(repositoryRoot, "pnpm-lock.yaml"),
];
const before = await Promise.all(watched.map(fingerprint));
const templateBefore = await treeListing(template);
const first = await packTemplate();
const second = await packTemplate();
expect(first.result.sha256).toBe(second.result.sha256);
expect(first.archive.equals(second.archive)).toBe(true);
expect(path.basename(first.result.archivePath)).toMatch(
/^example-\d+\.\d+\.\d+-any\.tar\.gz$/u,
);
expect(await Promise.all(watched.map(fingerprint))).toEqual(before);
expect(await treeListing(template)).toEqual(templateBefore);
});
it("packages the canonical template as ABI v1 with Drizzle private to the module", async () => {
const { archive, result } = await packTemplate();
const files = fileEntries(archive);
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
const canonicalLicense = await readFile(path.join(repositoryRoot, "LICENSE"));
const packagedLicense = files.get("example/LICENSE");
expect(manifest.database).toEqual({ abi: 1 });
expect(metadata.license).toBe("AGPL-3.0-or-later");
expect(packagedLicense).toEqual(canonicalLicense);
expect(metadata.files.filter((file: { path: string }) => file.path === "LICENSE")).toEqual([
{
path: "LICENSE",
size: canonicalLicense.length,
sha256: createHash("sha256").update(canonicalLicense).digest("hex"),
},
]);
expect(metadata.transitional).toBeUndefined();
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
});
expect(
[...files.entries()].some(
([file, data]) => file.endsWith(".js") && data.toString("utf8").includes("drizzle-orm"),
),
).toBe(true);
await expect(
verifyModulePackage({
archivePath: result.archivePath,
expectedSha256: result.sha256,
platform: await platform("99.0.0"),
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("verifies against this platform and extracts to an unrelated directory that holds no link", async () => {
const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example");
const verified = await verifyModulePackage({
archivePath: result.archivePath,
expectedSha256: result.sha256,
extractTo: target,
platform: await platform(),
});
expect(verified).toMatchObject({ moduleId: "example", target: "any" });
for (const entry of await readdir(target, { recursive: true, withFileTypes: true })) {
const stats = await lstat(path.join(entry.parentPath, entry.name));
expect(stats.isSymbolicLink(), entry.name).toBe(false);
expect(stats.isFile() ? stats.nlink : 1, entry.name).toBe(1);
}
const listing = await treeListing(target);
expect(listing).not.toContain("node_modules");
expect(listing.some((file) => /(^|\/)(src|tests)(\/|$)|\.d\.ts$|\.map$/u.test(file))).toBe(
false,
);
});
it("loads from its new location without resolving anything outside itself", async () => {
const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-portable-"), "example");
await verifyModulePackage({
archivePath: result.archivePath,
extractTo: target,
platform: await platform(),
});
// Every specifier the backend resolves while loading, recorded by Node itself.
const script = `
import { registerHooks } from "node:module";
import { pathToFileURL } from "node:url";
const resolved = [];
registerHooks({ resolve(specifier, context, next) { const result = next(specifier, context); resolved.push(result.url); return result; } });
for (const file of ["dist/server/index.js", "dist/schema/index.js"]) {
await import(pathToFileURL(${JSON.stringify(target)} + "/" + file).href);
}
process.stdout.write(JSON.stringify(resolved));
`;
const { stdout } = await execFileAsync(
process.execPath,
["--input-type=module", "-e", script],
{
cwd: path.dirname(target),
env: {},
},
);
const resolved = JSON.parse(stdout) as string[];
expect(resolved.length).toBeGreaterThan(0);
for (const url of resolved) {
const inside = url.startsWith(`file://${target}/`);
expect(inside || url.startsWith("node:"), url).toBe(true);
}
});
it("credits every third-party package its bundles say they contain", async () => {
const { archive } = await packTemplate();
const files = fileEntries(archive);
const notices = files.get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
const bundled = new Set<string>();
for (const [file, data] of files) {
if (file.endsWith(".js")) {
// esbuild names every bundled source file in a path comment.
for (const match of data
.toString("utf8")
.matchAll(/node_modules\/((?:@[^/]+\/)?[^/.][^/]*)\//gu)) {
if (match[1] && !match[1].startsWith("@ekklesia/")) {
bundled.add(match[1]);
}
}
}
}
expect([...bundled]).toEqual(
expect.arrayContaining(["drizzle-orm", "react", "react-dom", "zod"]),
);
for (const name of bundled) {
expect(notices, name).toMatch(
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
);
}
expect(notices).not.toContain("@ekklesia/module-template");
expect(notices).not.toMatch(/^@ekklesia\//mu);
expect(bundled).not.toContain("spdx-expression-parse");
expect(bundled).not.toContain("spdx-exceptions");
expect(notices).not.toContain("spdx-expression-parse");
expect(notices).not.toContain("spdx-exceptions");
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
expect(createHash("sha256").update(licence).digest("hex")).toBe(
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
);
});
it("refers to no absolute path of the checkout it was built in", async () => {
const { archive } = await packTemplate();
for (const [file, data] of fileEntries(archive)) {
expect(data.toString("utf8"), file).not.toContain(repositoryRoot);
}
});
});