Files
Ekklesia/core/tests/module-package-verification.test.ts
T
ekklesiaandClaude Opus 5.5 35ef31df9f
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
A separately versioned module is now distributed as one deterministic
.tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by
Core before anything is installed. This slice implements the portable `any`
target, with the module template as the end-to-end proof. ADR 0049 is
accepted.

@ekklesia/contracts: the Module Package v1 contract. The strict
ekklesia.package.json schema (packageFormat 1, identity, target, runtime,
provenance, incorporated Ekklesia packages, strict transitional facts, an
exact byte-wise-sorted file list with size and SHA-256), target names, and the
generated runtime package.json.

@ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir>
--target any --out <dir> --source-revision <commit>`.
- Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle
  included; only Node built-ins remain imports. Any warning, unresolved
  import, external dependency, or native or platform-specific package fails.
- Copies browser artifacts as built, after proving them current, and the
  migrations; generates package.json and ekklesia.package.json; never ships
  src, tests, declarations, source maps, or workspace ranges.
- THIRD_PARTY_NOTICES: every third-party package traced from the bundles'
  own inputs, credited once with its declared licence and every licence,
  copying, copyright, and notice file it ships. A missing licence fails; a
  missing text fails unless its standard text is complete without copyright
  lines (Apache-2.0, vendored, needed by drizzle-orm).
- Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime
  0, 0755/0644, no links. Byte-identical across runs and machines.
- Writes only the archive and its .sha256; never deploys, links, or installs,
  so the workspace's install state is untouched.

Core: verifyModulePackage and `verify-module-package.js <archive>
[--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in
memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe
or escaping paths, and a second top-level directory; checks metadata, every
file, identity, target, Node.js range, SDK, and the transitional Drizzle
agreement; runs the platform's module and migration discovery; loads the
backend once in a forked process with an empty environment; only then
extracts, with normalized modes, into a new directory. Errors are sanitized.

@ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs
and whether the built file is current. The nested stylesheet build now works
from the module directory, so artifacts no longer depend on the caller's
working directory.

The split deploy smoke builds the package with the canonical packager
(scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm
deploy packaging), verifies it with the same generic image, installs the
verified tree, removes it, and reinstalls the exact same archive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 12:44:54 -05:00

388 lines
13 KiB
TypeScript

import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
type ModulePackageVerificationErrorCode,
ModulePackageVerificationError,
verifyModulePackage,
} from "../src/modules/index.js";
import {
type FixtureEntry,
modulePackageFixture,
type PackageFixtureOptions,
sha256,
} from "./support/module-package-fixture.js";
const platform = {
databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.1.2",
platformVersion: "0.1.2",
} as const;
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function scratch(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-"));
temporaryDirectories.push(directory);
return directory;
}
async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
readonly archivePath: string;
readonly sha256: string;
}> {
const fixture = await modulePackageFixture(options);
const archivePath = path.join(await scratch(), "private-host-name-5e1d.tar.gz");
await writeFile(archivePath, fixture.archive);
return { archivePath, sha256: fixture.sha256 };
}
async function rejection(
options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode,
extra: { readonly expectedSha256?: string } = {},
): Promise<ModulePackageVerificationError> {
const { archivePath } = await archiveFile(options);
try {
await verifyModulePackage({ archivePath, platform, ...extra });
} catch (error) {
expect(error).toBeInstanceOf(ModulePackageVerificationError);
const verificationError = error as ModulePackageVerificationError;
expect(verificationError.code).toBe(code);
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
expect(rendered).not.toContain("private-host-name-5e1d");
expect(rendered).not.toContain(tmpdir());
expect(rendered).not.toContain("attacker-chosen");
return verificationError;
}
throw new Error(`Expected ${code}.`);
}
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] };
}
describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile();
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
expect(result).toEqual({
archiveSha256: digest,
fileCount: 8,
moduleId: "example",
target: "any",
version: "0.1.0",
});
});
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example");
await verifyModulePackage({ archivePath, extractTo: target, platform });
const names = (await readdir(target, { recursive: true })).sort();
expect(names).toContain("ekklesia.package.json");
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);
const { archivePath: broken } = await archiveFile({ runtimePackage: { name: "x" } });
const refused = path.join(await scratch(), "refused");
await expect(
verifyModulePackage({ archivePath: broken, extractTo: refused, platform }),
).rejects.toBeInstanceOf(ModulePackageVerificationError);
await expect(stat(refused)).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses to extract over an existing directory", async () => {
const { archivePath } = await archiveFile();
const existing = await scratch();
await expect(
verifyModulePackage({ archivePath, extractTo: existing, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_EXTRACTION_FAILED" });
});
it("fails a wrong archive digest", async () => {
await rejection({}, "MODULE_PACKAGE_DIGEST_MISMATCH", { expectedSha256: sha256("other") });
});
it("fails a payload whose contents no longer match their digest", async () => {
await rejection(
{
tamper: (files) => {
files.set("dist/server/index.js", Buffer.from("export default {}; // attacker-chosen\n"));
},
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("fails a listed file missing from the archive", async () => {
await rejection(
{ tamper: (files) => void files.delete("THIRD_PARTY_NOTICES") },
"MODULE_PACKAGE_FILE_MISSING",
);
});
it("fails an archive file that is not listed", async () => {
await rejection(
withEntry({ path: "example/attacker-chosen.js", data: "extra" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it("fails when the manifest and the package disagree about the version", async () => {
await rejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails when the metadata names another module", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails a runtime package.json that is not the generated shape", async () => {
await rejection(
{
runtimePackage: {
name: "@test/module-example",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
},
},
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
);
});
it("fails an unsupported package format", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
);
});
it("fails transitional metadata it does not understand", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
}),
},
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
});
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
}),
},
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
);
const { archivePath } = await archiveFile({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
});
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
moduleId: "example",
});
});
it("fails a target this platform cannot run", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
target: "linux-arm64-musl",
runtime: { node: ">=20", nodeApi: 9 },
}),
},
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
});
it("fails a native file inside an any-target package", async () => {
const native = await modulePackageFixture({
tamper: (files) => {
files.set("dist/addon.node", Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
},
metadata: (metadata) => {
const files = [
...(metadata.files as { path: string; size: number; sha256: string }[]),
{
path: "dist/addon.node",
size: 4,
sha256: sha256(Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
},
].sort((left, right) => (left.path < right.path ? -1 : 1));
return { ...metadata, files };
},
});
const archivePath = path.join(await scratch(), "native.tar.gz");
await writeFile(archivePath, native.archive);
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_NATIVE_FILE",
});
});
it("fails a package that needs a newer Node.js", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, runtime: { node: ">=99" } }) },
"MODULE_PACKAGE_RUNTIME_INCOMPATIBLE",
);
});
it("fails an incorporated SDK outside the manifest's own range", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
incorporates: { "@ekklesia/module-sdk": "0.9.0" },
}),
},
"MODULE_PACKAGE_SDK_INCOMPATIBLE",
);
});
it("fails a module whose declared entrypoint is missing", async () => {
await rejection(
{
manifest: (manifest) => ({
...manifest,
version: "0.1.0",
entrypoints: {
...(manifest.entrypoints as Record<string, unknown>),
schema: "./dist/schema/index.js",
},
}),
},
"MODULE_PACKAGE_MODULE_INVALID",
);
});
it("fails a backend that imports a dependency the package does not contain", async () => {
const error = await rejection(
{
serverSource:
'import pad from "ekklesia-dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
},
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(error.message).not.toContain("ekklesia-dependency-not-in-this-package");
});
it("fails a backend whose default export names another module", async () => {
await rejection(
{ serverSource: 'export default { id: "other", version: "0.1.0" };\n' },
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
});
it.each([
["an absolute path", { path: "/etc/attacker-chosen", data: "x" }, "MODULE_PACKAGE_UNSAFE_PATH"],
[
"a .. segment",
{ path: "example/../attacker-chosen", data: "x" },
"MODULE_PACKAGE_UNSAFE_PATH",
],
[
"a symbolic link",
{ path: "example/link", type: "symlink", linkname: "/etc/passwd" },
"MODULE_PACKAGE_LINK",
],
[
"a hard link",
{ path: "example/hard", type: "hardlink", linkname: "example/package.json" },
"MODULE_PACKAGE_LINK",
],
[
"a device node",
{ path: "example/device", type: "character" },
"MODULE_PACKAGE_SPECIAL_ENTRY",
],
["a FIFO", { path: "example/fifo", type: "fifo" }, "MODULE_PACKAGE_SPECIAL_ENTRY"],
[
"a setuid file",
{ path: "example/setuid", data: "x", mode: 0o4755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a setgid directory",
{ path: "example/setgid", type: "directory", mode: 0o2755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a second top-level directory",
{ path: "attacker-chosen/file", data: "x" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
[
"a duplicate entry",
{ path: "example/package.json", data: "{}" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
] as const)("fails %s", async (_label, entry, code) => {
await rejection(withEntry(entry as FixtureEntry), code);
});
it("fails a file that is not a gzip-compressed tar archive", async () => {
const archivePath = path.join(await scratch(), "plain.tar.gz");
await writeFile(archivePath, "not an archive");
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_UNREADABLE",
});
});
it("fails an archive without package metadata", async () => {
const fixture = await modulePackageFixture();
const { gunzipSync, gzipSync } = await import("node:zlib");
// Rebuild without the metadata entry by filtering the fixture through a fresh archive.
const archivePath = path.join(await scratch(), "no-metadata.tar.gz");
const raw = gunzipSync(fixture.archive);
const blocks: Buffer[] = [];
for (let offset = 0; offset + 512 <= raw.length; ) {
const headerBlock = raw.subarray(offset, offset + 512);
if (headerBlock.every((byte) => byte === 0)) {
break;
}
const name = headerBlock.subarray(0, 100).toString("latin1").replace(/\0.*$/su, "");
const size = Number.parseInt(headerBlock.subarray(124, 136).toString("latin1"), 8);
const length = 512 + Math.ceil(size / 512) * 512;
if (!name.endsWith("ekklesia.package.json")) {
blocks.push(raw.subarray(offset, offset + length));
}
offset += length;
}
await writeFile(archivePath, gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])));
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_METADATA_MISSING",
});
});
});