feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
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

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]>
This commit is contained in:
ekklesia
2026-10-07 12:44:54 -05:00
co-authored by Claude Opus 5.5
parent 45d6506c10
commit 35ef31df9f
45 changed files with 4876 additions and 151 deletions
+1
View File
@@ -8,6 +8,7 @@ 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";
+203
View File
@@ -0,0 +1,203 @@
import { z } from "zod";
import { moduleIdSchema } from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
/**
* Ekklesia Module Package v1 (ADR 0049): the format a separately versioned module is distributed
* in. One deterministic `.tar.gz` holding exactly one top-level directory named with the module id,
* whose contents are the installed module directory ADR 0048 requires. This file defines the
* package's metadata (`ekklesia.package.json`), its target names, and the runtime `package.json`
* the packager generates. The archive encoding itself belongs to the packager and the verifier.
*/
export const MODULE_PACKAGE_FORMAT = 1;
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";
/**
* `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
* not load against the other; elsewhere it is meaningless and refused.
*/
export const modulePackageTargetSchema = z.string().superRefine((value, context) => {
if (value === MODULE_PACKAGE_ANY_TARGET) {
return;
}
const match = /^([a-z][a-z0-9]*)-([a-z][a-z0-9]*)(?:-([a-z]+))?$/u.exec(value);
const platform = match?.[1];
const libc = match?.[3];
const valid =
match !== null &&
platform !== "any" &&
(platform === "linux" ? libc === "glibc" || libc === "musl" : libc === undefined);
if (!valid) {
context.addIssue({
code: "custom",
message: "Must be any, or <platform>-<arch>, with -glibc or -musl on Linux.",
});
}
});
/** A path inside the module directory: relative, POSIX, printable ASCII, and never escaping. ASCII
* keeps JavaScript's string order identical to the archive's byte-wise order. */
const payloadPathSchema = z.string().superRefine((value, context) => {
const segments = value.split("/");
const valid =
/^[\x20-\x7e]+$/u.test(value) &&
!value.includes("\\") &&
!value.startsWith("/") &&
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") &&
value !== MODULE_PACKAGE_METADATA_FILENAME;
if (!valid) {
context.addIssue({
code: "custom",
message: "Must be a portable path inside the module directory, other than the metadata file.",
});
}
});
const modulePackageFileSchema = z.strictObject({
path: payloadPathSchema,
size: z.number().int().nonnegative(),
sha256: z.string().regex(/^[0-9a-f]{64}$/u, "Must be a lowercase hexadecimal SHA-256."),
});
const modulePackageRuntimeSchema = z.strictObject({
/** The Node.js versions the package was built for. Always present. */
node: semanticVersionRangeSchema,
/** Native packages only: the minimum Node-API version their addons need. */
nodeApi: z.number().int().positive().optional(),
/** Native packages only: the exact `process.versions.modules` an ABI-specific addon needs. */
nodeAbi: z
.string()
.regex(/^[0-9]+$/u)
.optional(),
/** Native Linux packages only: the minimum C library version. */
libcVersion: semanticVersionRangeSchema.optional(),
});
const ekklesiaPackageNameSchema = z
.string()
.regex(/^@ekklesia\/[a-z0-9][a-z0-9-]*$/u, "Must name an @ekklesia package.");
/**
* Compatibility facts today's implementation needs and that are not part of the long-term contract
* (ADR 0049). Strict at every level: a reader that finds a fact it does not understand must refuse
* the package rather than ignore it.
*
* `databaseRuntime` records the Drizzle version bundled with the module's backend, because Core runs
* a module's queries through its own Drizzle instance over tables the module defined with its own
* copy. That is an implementation constraint pending a Module Database ABI decision, **not** a
* database contract a module may rely on.
*/
const modulePackageTransitionalSchema = z.strictObject({
databaseRuntime: z
.strictObject({
"drizzle-orm": semanticVersionSchema.optional(),
})
.optional(),
});
export const modulePackageMetadataSchema = z
.strictObject({
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
target: modulePackageTargetSchema,
runtime: modulePackageRuntimeSchema,
provenance: z.strictObject({
/** The exact source revision the package was built from: a full SHA-1 or SHA-256 commit id. */
revision: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
/** Informational only: nothing trusts it, and it ties the package to no host. */
repository: z.string().min(1).max(2048).optional(),
packager: z.strictObject({
name: z.string().min(1).max(214),
version: semanticVersionSchema,
}),
}),
/** The exact versions of the Ekklesia SDK and runtime libraries bundled into the package. */
incorporates: z.record(ekklesiaPackageNameSchema, semanticVersionSchema),
transitional: modulePackageTransitionalSchema.optional(),
/** Every regular file in the module directory except this metadata file, in byte-wise path
* order. The set is exact: nothing missing, nothing extra. */
files: z.array(modulePackageFileSchema),
})
.superRefine((metadata, context) => {
if (
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
(metadata.runtime.nodeApi !== undefined ||
metadata.runtime.nodeAbi !== undefined ||
metadata.runtime.libcVersion !== undefined)
) {
context.addIssue({
code: "custom",
message: "An any-target package carries no native runtime requirement.",
path: ["runtime"],
});
}
for (let index = 1; index < metadata.files.length; index += 1) {
const previous = metadata.files[index - 1]?.path ?? "";
const current = metadata.files[index]?.path ?? "";
if (!(previous < current)) {
context.addIssue({
code: "custom",
message: "Files must be listed once each, in byte-wise path order.",
path: ["files", index, "path"],
});
}
}
});
export type ModulePackageMetadata = z.output<typeof modulePackageMetadataSchema>;
export type ModulePackageFile = z.output<typeof modulePackageFileSchema>;
export type ModulePackageTarget = z.output<typeof modulePackageTargetSchema>;
export function safeParseModulePackageMetadata(input: unknown) {
return modulePackageMetadataSchema.safeParse(input);
}
export function safeParseModulePackageTarget(input: unknown) {
return modulePackageTargetSchema.safeParse(input);
}
/**
* The runtime `package.json` of an installed module: exactly what module discovery reads, generated
* by the packager and never copied from a development workspace. No dependencies, scripts, exports,
* or imports, because nothing at run time resolves through them.
*/
export const modulePackageRuntimePackageSchema = z.strictObject({
name: z.string().min(1).max(214),
version: semanticVersionSchema,
private: z.literal(true),
type: z.literal("module"),
ekklesia: z.strictObject({ layer: z.literal("module") }),
});
export type ModulePackageRuntimePackage = z.output<typeof modulePackageRuntimePackageSchema>;
export function modulePackageRuntimePackage(identity: {
readonly name: string;
readonly version: string;
}): ModulePackageRuntimePackage {
return {
name: identity.name,
version: identity.version,
private: true,
type: "module",
ekklesia: { layer: "module" },
};
}
export function safeParseModulePackageRuntimePackage(input: unknown) {
return modulePackageRuntimePackageSchema.safeParse(input);
}
/** The recommended archive file name. A convenience only: a file name is never trusted. */
export function modulePackageArchiveFileName(
moduleId: string,
version: string,
target: string,
): string {
return `${moduleId}-${version}-${target}.tar.gz`;
}
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import {
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
modulePackageArchiveFileName,
modulePackageRuntimePackage,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
safeParseModulePackageTarget,
} from "../src/index.js";
const digest = "a".repeat(64);
function metadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
packageFormat: 1,
module: { id: "example", version: "0.1.0" },
target: "any",
runtime: { node: ">=24.11.0 <25" },
provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
},
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
files: [
{ 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 },
{ path: "package.json", size: 40, sha256: digest },
],
...overrides,
};
}
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_METADATA_FILENAME).toBe("ekklesia.package.json");
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
"example-0.1.0-any.tar.gz",
);
});
it("accepts a complete any-target package description", () => {
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
});
it("accepts a package that incorporates nothing and needs no transitional facts", () => {
const { transitional: _transitional, ...rest } = metadata();
expect(safeParseModulePackageMetadata({ ...rest, incorporates: {} }).success).toBe(true);
});
it.each([
["an unknown top-level field", { signature: "x" }],
["format 2", { packageFormat: 2 }],
["a missing source revision", { provenance: { packager: { name: "p", version: "1.0.0" } } }],
[
"an abbreviated revision",
{ provenance: { revision: "86ae907", packager: { name: "p", version: "1.0.0" } } },
],
["a non-semver module version", { module: { id: "example", version: "1.0" } }],
["an invalid module id", { module: { id: "Example", version: "0.1.0" } }],
["an invalid Node range", { runtime: { node: "soon" } }],
["native runtime facts on an any target", { runtime: { node: ">=24", nodeApi: 9 } }],
["a non-Ekklesia incorporated package", { incorporates: { zod: "4.4.3" } }],
[
"an incorporated range instead of a version",
{ incorporates: { "@ekklesia/module-sdk": "^0.1.0" } },
],
["an unknown transitional key", { transitional: { orm: {} } }],
["an unknown database runtime", { transitional: { databaseRuntime: { prisma: "6.0.0" } } }],
])("rejects %s", (_label, overrides) => {
expect(safeParseModulePackageMetadata(metadata(overrides)).success).toBe(false);
});
it.each([
["an absolute path", "/etc/passwd"],
["a parent segment", "dist/../../secret"],
["a dot segment", "./package.json"],
["an empty segment", "dist//index.js"],
["a backslash", "dist\\index.js"],
["a NUL byte", "dist/\u0000"],
["the metadata file itself", "ekklesia.package.json"],
])("rejects a file entry with %s", (_label, path) => {
const files = [{ path, size: 1, sha256: digest }];
expect(safeParseModulePackageMetadata(metadata({ files })).success).toBe(false);
});
it("requires file entries in byte-wise path order, without duplicates", () => {
const entry = (path: string) => ({ path, size: 1, sha256: digest });
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("b"), entry("a")] })).success,
).toBe(false);
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("a"), entry("a")] })).success,
).toBe(false);
// Byte-wise, not locale order: upper case sorts before lower case.
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success,
).toBe(true);
});
it.each([
["an uppercase digest", { sha256: "A".repeat(64) }],
["a short digest", { sha256: "a".repeat(63) }],
["a negative size", { size: -1 }],
["a fractional size", { size: 1.5 }],
])("rejects a file entry with %s", (_label, override) => {
const files = [{ path: "package.json", size: 1, sha256: digest, ...override }];
expect(safeParseModulePackageMetadata(metadata({ files })).success).toBe(false);
});
});
describe("module package targets", () => {
it.each([
"any",
"linux-x64-glibc",
"linux-arm64-glibc",
"linux-x64-musl",
"darwin-arm64",
])("accepts %s", (target) => {
expect(safeParseModulePackageTarget(target).success).toBe(true);
});
it.each([
"",
"ANY",
"linux-x64",
"linux-x64-gnu",
"darwin-arm64-glibc",
"linux",
"any-x64",
])("rejects %j", (target) => {
expect(safeParseModulePackageTarget(target).success).toBe(false);
});
});
describe("the generated runtime package.json", () => {
it("holds exactly what module discovery reads", () => {
expect(
modulePackageRuntimePackage({ name: "@ekklesia/module-template", version: "0.1.0" }),
).toEqual({
name: "@ekklesia/module-template",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
});
});
it("accepts the generated shape and nothing more", () => {
const generated = modulePackageRuntimePackage({ name: "@scope/example", version: "1.2.3" });
expect(safeParseModulePackageRuntimePackage(generated).success).toBe(true);
for (const extra of [
{ dependencies: { "@ekklesia/module-sdk": "workspace:*" } },
{ scripts: { build: "tsc" } },
{ exports: { ".": "./dist/server/index.js" } },
{ imports: { "#x": "./x.js" } },
]) {
expect(safeParseModulePackageRuntimePackage({ ...generated, ...extra }).success).toBe(false);
}
expect(safeParseModulePackageRuntimePackage({ ...generated, type: "commonjs" }).success).toBe(
false,
);
});
});