feat: Module Package v1 carries the module's own license (ADR 0051) #74

Merged
Zayin_Admin merged 1 commits from feat/module-package-v1-licensing into main 2026-10-08 00:03:02 +00:00
22 changed files with 400 additions and 40 deletions
Showing only changes of commit 85fae9e07e - Show all commits
+3 -1
View File
@@ -49,7 +49,9 @@ as one **Ekklesia Module Package v1**
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and ([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
so far. ABI-v1 packages may bundle a module-owned Drizzle version without coupling it to Core; so far. Every package records the module's validated SPDX expression and carries its explicitly
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
packages may bundle a module-owned Drizzle version without coupling it to Core;
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages, legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
signing, and any installer or marketplace do not exist yet. signing, and any installer or marketplace do not exist yet.
@@ -6,15 +6,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
isVersionCompatible, isVersionCompatible,
type ModuleManifest,
safeParseModuleManifest,
} from "@ekklesia/contracts";
import {
MODULE_PACKAGE_ANY_TARGET, MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT, MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME, MODULE_PACKAGE_METADATA_FILENAME,
type ModuleManifest,
type ModulePackageMetadata, type ModulePackageMetadata,
safeParseModuleManifest,
safeParseModulePackageMetadata, safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage, safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts/module-package";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js"; import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js"; import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import { import {
+84 -1
View File
@@ -91,7 +91,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform }); const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
expect(result).toEqual({ expect(result).toEqual({
archiveSha256: digest, archiveSha256: digest,
fileCount: 8, fileCount: 9,
moduleId: "example", moduleId: "example",
target: "any", target: "any",
version: "0.1.0", version: "0.1.0",
@@ -104,6 +104,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
await verifyModulePackage({ archivePath, extractTo: target, platform }); await verifyModulePackage({ archivePath, extractTo: target, platform });
const names = (await readdir(target, { recursive: true })).sort(); const names = (await readdir(target, { recursive: true })).sort();
expect(names).toContain("ekklesia.package.json"); expect(names).toContain("ekklesia.package.json");
expect(names).toContain("LICENSE");
expect(names).toContain("dist/server/index.js"); expect(names).toContain("dist/server/index.js");
expect((await stat(target)).mode & 0o7777).toBe(0o755); expect((await stat(target)).mode & 0o7777).toBe(0o755);
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644); expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
@@ -153,6 +154,88 @@ describe("Module Package v1 verification (ADR 0049)", () => {
); );
}); });
it.each([
["a missing license declaration", undefined],
["an empty license declaration", ""],
["UNLICENSED", "UNLICENSED"],
["a malformed SPDX expression", "MIT OR"],
])("fails package metadata with %s", async (_label, license) => {
await rejection(
{
metadata: (metadata) => {
const changed: Record<string, unknown> = { ...metadata, license };
if (license === undefined) {
delete changed.license;
}
return changed;
},
},
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("fails closed on pre-ADR-0051 metadata", async () => {
await rejection(
{
metadata: (metadata) => {
const old: Record<string, unknown> = { ...metadata };
delete old.license;
return old;
},
},
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
await rejection(
{ tamper: (files) => void files.delete("LICENSE") },
"MODULE_PACKAGE_FILE_MISSING",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
}),
},
"MODULE_PACKAGE_METADATA_INVALID",
);
await rejection(
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
await rejection(
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it("checks the listed LICENSE bytes, size, and digest", async () => {
await rejection(
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
"MODULE_PACKAGE_FILE_MISMATCH",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
),
}),
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("rejects a duplicate LICENSE archive entry", async () => {
await rejection(
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
"MODULE_PACKAGE_LAYOUT_INVALID",
);
});
it("fails when the manifest and the package disagree about the version", async () => { it("fails when the manifest and the package disagree about the version", async () => {
await rejection( await rejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) }, { manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
@@ -162,6 +162,7 @@ export async function modulePackageFixture(
), ),
); );
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n")); files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
files.set("LICENSE", Buffer.from("Example module license text.\n"));
const listed = [...files.entries()] const listed = [...files.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
@@ -169,6 +170,7 @@ export async function modulePackageFixture(
const baseMetadata: Record<string, unknown> = { const baseMetadata: Record<string, unknown> = {
packageFormat: 1, packageFormat: 1,
module: { id, version }, module: { id, version },
license: "MIT",
target: "any", target: "any",
runtime: { node: ">=20" }, runtime: { node: ">=20" },
provenance: { provenance: {
@@ -1,6 +1,6 @@
# 0051: Module packages declare their own license and carry its text # 0051: Module packages declare their own license and carry its text
- Status: Proposed - Status: Accepted
- Date: 2026-10-07 - Date: 2026-10-07
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an - Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1. authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
+1 -1
View File
@@ -59,4 +59,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted - [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted - [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted - [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Proposed - [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
+4 -1
View File
@@ -679,7 +679,10 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript - **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest, migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
and `THIRD_PARTY_NOTICES`, and writes the archive. copies the explicitly supplied module `LICENSE` bytes, generates `THIRD_PARTY_NOTICES` for bundled
third parties, and writes the archive. The source module's string SPDX expression is required and
becomes the package metadata's authoritative `license`; the packager neither infers nor downloads
license material.
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links, - **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
special files, and escaping paths. It checks every digest and identity, the target and runtime, special files, and escaping paths. It checks every digest and identity, the target and runtime,
runs the platform's own module, schema, and migration validation, and loads the backend once in a runs the platform's own module, schema, and migration validation, and loads the backend once in a
+15 -9
View File
@@ -598,26 +598,31 @@ CI / migration-smoke (pull_request)
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`: Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, target - `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, required
names, and the generated runtime `package.json`. SPDX expression and `LICENSE` inventory entry, target names, and the generated runtime
`package.json`.
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives, - `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
including what the packager never produces. A valid package verifies and extracts with normalized including what the packager never produces. A valid package verifies and extracts with normalized
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
metadata, a missing entrypoint, a backend that imports something the package lacks, absolute and metadata, missing or malformed license metadata, missing, unlisted, duplicated, or corrupted
`LICENSE`, a missing entrypoint, a backend that imports something the package lacks, absolute and
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second `..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
load check runs in a real child process and selects the schema contract from the manifest. load check runs in a real child process and selects the schema contract from the manifest.
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents, - `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
and platform-specific dependencies and of unknown or textless licences, notices, byte-identical and platform-specific dependencies and of unknown or textless third-party licences, explicit
archives, normalized tar and gzip headers), and end to end against the built module template: module SPDX and license-file validation, unchanged `LICENSE` bytes and inventory digest, notices,
byte-identical archives, normalized tar and gzip headers), and end to end against the built module template:
packed twice to identical bytes without changing the workspace's install state, verified by Core's packed twice to identical bytes without changing the workspace's install state, verified by Core's
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
Node's own resolution hooks proving nothing outside the package is resolved. The package contains Node's own resolution hooks proving nothing outside the package is resolved. The package contains
module-side Drizzle but no `transitional.databaseRuntime`. Turbo builds the template and Core the repository's exact AGPL `LICENSE`, authoritative `AGPL-3.0-or-later` metadata, module-side
first, and runs the template's own tests (which rebuild its UI in place) before these. Drizzle but no `transitional.databaseRuntime`, and no tooling-only SPDX parser dependency. Turbo
builds the template and Core first, and runs the template's own tests (which rebuild its UI in
place) before these.
## The generic platform image and the reference deployment ## The generic platform image and the reference deployment
@@ -636,8 +641,9 @@ These need Docker and build images locally; nothing is pushed or published.
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the - `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
runs the installed-module lifecycle: runs the installed-module lifecycle:
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049), - the module template is packaged by the canonical packager (Module Package v1, ADR 0049 and
as ABI v1, verified by the image's own verifier, and installed into module storage; ADR 0051), with its explicit AGPL declaration and license bytes, as ABI v1, verified by the
image's own verifier, and installed into module storage;
- its migration is planned and explicitly applied, and the API restarted; - its migration is planned and explicitly applied, and the API restarted;
- a real browser mounts it; - a real browser mounts it;
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and - it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
+22 -10
View File
@@ -97,6 +97,7 @@ development dependency like `@ekklesia/module-ui-build`:
```bash ```bash
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \ pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
--license-file ./LICENSE \
--source-revision "$(git rev-parse HEAD)" --source-revision "$(git rev-parse HEAD)"
``` ```
@@ -110,7 +111,8 @@ The archive holds one directory named with your module id:
<id>/ <id>/
├── package.json generated: name, version, private, "type": "module", the layer marker ├── package.json generated: name, version, private, "type": "module", the layer marker
├── ekklesia.module.json your manifest, byte for byte ├── ekklesia.module.json your manifest, byte for byte
├── ekklesia.package.json package metadata: target, runtime, provenance, file digests ├── ekklesia.package.json package metadata: license, target, runtime, provenance, file digests
├── LICENSE your explicitly supplied module license material, byte for byte
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled ├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
├── dist/server/index.js your backend entrypoints, bundled ├── dist/server/index.js your backend entrypoints, bundled
├── dist/schema/index.js ├── dist/schema/index.js
@@ -130,11 +132,18 @@ The archive holds one directory named with your module id:
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled - **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later. such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
- **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with - **Your module's licence.** Your source `package.json` must contain a non-empty string SPDX
its declared licence and the licence and notice files it ships. A package that declares no licence expression. The packager validates its syntax, rejects `UNLICENSED`, and copies it unchanged to
fails packaging, and so does one that ships no licence text, unless its licence's standard text is the required `ekklesia.package.json.license`. `--license-file` is also required: it names the
complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The regular file whose exact bytes become the root `LICENSE` payload and inventory entry. The
Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`. packager never searches parent directories, guesses text from the expression, or downloads it.
- **Third-party licences.** `THIRD_PARTY_NOTICES` separately credits every third-party package your
bundles contain, with its declared licence and the licence and notice files it ships. A dependency
that declares no licence fails packaging, and so does one that ships no licence text, unless its
licence's standard text is complete without a copyright line (Apache-2.0, which the packager then
supplies and says so). The module itself is not added to this notice file. Bundled Ekklesia
packages remain listed in `ekklesia.package.json` under `incorporates`; one applicable AGPL text
is sufficient for current official modules.
- **Deterministic.** The same packager version and the same built inputs produce the same archive, - **Deterministic.** The same packager version and the same built inputs produce the same archive,
byte for byte, wherever the checkout is. byte for byte, wherever the checkout is.
@@ -167,10 +176,13 @@ later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekkl
same license. Declare it in your module's development `package.json` same license. Declare it in your module's development `package.json`
(`"license": "AGPL-3.0-or-later"`), as the module template does. (`"license": "AGPL-3.0-or-later"`), as the module template does.
A Module Package v1 does not yet record a module's own license: its runtime `package.json` and A Module Package v1 records that validated expression in its required
`ekklesia.package.json` have fixed fields, and its contents are fixed, by ADR 0049. Carrying the `ekklesia.package.json.license` and contains the exact explicitly supplied bytes at `LICENSE`
module's license, and the license text for the Ekklesia code bundled into its backend, needs a ([ADR 0051](../architecture/decisions/0051-module-package-license-material.md)). The generated
change to that format. runtime `package.json` is not authoritative for licensing. `THIRD_PARTY_NOTICES` remains only for
redistributed third-party dependencies, while bundled Ekklesia components remain represented by
`incorporates`. This strict-reader addition keeps `packageFormat` at `1`; older pre-0051 artifacts
without the field and payload fail closed.
### Removing a module keeps its data ### Removing a module keeps its data
+6
View File
@@ -10,6 +10,10 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./module-package": {
"types": "./dist/module-package.d.ts",
"import": "./dist/module-package.js"
} }
}, },
"scripts": { "scripts": {
@@ -22,12 +26,14 @@
"dependencies": { "dependencies": {
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"semver": "7.8.5", "semver": "7.8.5",
"spdx-expression-parse": "5.0.0",
"zod": "4.4.3" "zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/spdx-expression-parse": "4.0.0",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
-1
View File
@@ -9,7 +9,6 @@ export * from "./module-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
export * from "./module-manifest.js"; export * from "./module-manifest.js";
export * from "./module-migration.js"; export * from "./module-migration.js";
export * from "./module-package.js";
export * from "./module-realtime.js"; export * from "./module-realtime.js";
export * from "./module-runtime.js"; export * from "./module-runtime.js";
export * from "./module-schema.js"; export * from "./module-schema.js";
+36
View File
@@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import parseSpdxExpression from "spdx-expression-parse";
import { moduleIdSchema } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.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_FORMAT = 1;
export const MODULE_PACKAGE_LICENSE_FILENAME = "LICENSE";
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json"; export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES"; export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
/** The portable target: a package with no native or platform-specific file. */ /** The portable target: a package with no native or platform-specific file. */
export const MODULE_PACKAGE_ANY_TARGET = "any"; 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` * `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 * 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({ .strictObject({
packageFormat: z.literal(MODULE_PACKAGE_FORMAT), packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }), module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
/** The module's authoritative package-release license declaration (ADR 0051). */
license: modulePackageLicenseExpressionSchema,
target: modulePackageTargetSchema, target: modulePackageTargetSchema,
runtime: modulePackageRuntimeSchema, runtime: modulePackageRuntimeSchema,
provenance: z.strictObject({ provenance: z.strictObject({
@@ -124,6 +146,16 @@ export const modulePackageMetadataSchema = z
files: z.array(modulePackageFileSchema), files: z.array(modulePackageFileSchema),
}) })
.superRefine((metadata, context) => { .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 ( if (
metadata.target === MODULE_PACKAGE_ANY_TARGET && metadata.target === MODULE_PACKAGE_ANY_TARGET &&
(metadata.runtime.nodeApi !== undefined || (metadata.runtime.nodeApi !== undefined ||
@@ -157,6 +189,10 @@ export function safeParseModulePackageMetadata(input: unknown) {
return modulePackageMetadataSchema.safeParse(input); return modulePackageMetadataSchema.safeParse(input);
} }
export function safeParseModulePackageLicenseExpression(input: unknown) {
return modulePackageLicenseExpressionSchema.safeParse(input);
}
export function safeParseModulePackageTarget(input: unknown) { export function safeParseModulePackageTarget(input: unknown) {
return modulePackageTargetSchema.safeParse(input); return modulePackageTargetSchema.safeParse(input);
} }
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
MODULE_PACKAGE_FORMAT, MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_LICENSE_FILENAME,
MODULE_PACKAGE_METADATA_FILENAME, MODULE_PACKAGE_METADATA_FILENAME,
modulePackageArchiveFileName, modulePackageArchiveFileName,
modulePackageRuntimePackage, modulePackageRuntimePackage,
safeParseModulePackageLicenseExpression,
safeParseModulePackageMetadata, safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage, safeParseModulePackageRuntimePackage,
safeParseModulePackageTarget, safeParseModulePackageTarget,
} from "../src/index.js"; } from "../src/module-package.js";
const digest = "a".repeat(64); const digest = "a".repeat(64);
@@ -15,6 +17,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
return { return {
packageFormat: 1, packageFormat: 1,
module: { id: "example", version: "0.1.0" }, module: { id: "example", version: "0.1.0" },
license: "AGPL-3.0-or-later",
target: "any", target: "any",
runtime: { node: ">=24.11.0 <25" }, runtime: { node: ">=24.11.0 <25" },
provenance: { 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" }, incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } }, transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
files: [ files: [
{ path: "LICENSE", size: 9, sha256: digest },
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest }, { path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
{ path: "dist/server/index.js", size: 20, sha256: digest }, { path: "dist/server/index.js", size: 20, sha256: digest },
{ path: "ekklesia.module.json", size: 30, 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)", () => { describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
it("names the format, its metadata file, and the recommended archive file name", () => { it("names the format, its metadata file, and the recommended archive file name", () => {
expect(MODULE_PACKAGE_FORMAT).toBe(1); expect(MODULE_PACKAGE_FORMAT).toBe(1);
expect(MODULE_PACKAGE_LICENSE_FILENAME).toBe("LICENSE");
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json"); expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe( expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
"example-0.1.0-any.tar.gz", "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", () => { it("accepts a complete any-target package description", () => {
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true); expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
}); });
@@ -97,7 +135,9 @@ describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
).toBe(false); ).toBe(false);
// Byte-wise, not locale order: upper case sorts before lower case. // Byte-wise, not locale order: upper case sorts before lower case.
expect( expect(
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success, safeParseModulePackageMetadata(
metadata({ files: [entry("LICENSE"), entry("THIRD"), entry("dist")] }),
).success,
).toBe(true); ).toBe(true);
}); });
+11 -3
View File
@@ -9,7 +9,7 @@ import { ModulePackError, type PackModuleOptions, packModule } from "./pack-modu
* `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049). * `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049).
* *
* ekklesia-pack-module --module <built module directory> --target any --out <directory> * ekklesia-pack-module --module <built module directory> --target any --out <directory>
* --source-revision <full commit id> [--repository <url>] * --license-file <file> --source-revision <full commit id> [--repository <url>]
* *
* Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing * Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing
* else anywhere. Run it in the module's CI after building the module. * else anywhere. Run it in the module's CI after building the module.
@@ -18,9 +18,10 @@ import { ModulePackError, type PackModuleOptions, packModule } from "./pack-modu
class PackArgumentsError extends Error {} class PackArgumentsError extends Error {}
const USAGE = const USAGE =
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --source-revision <commit> [--repository <url>]"; "Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --license-file <file> --source-revision <commit> [--repository <url>]";
const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({ const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({
"--license-file": "licenseFile",
"--module": "moduleDirectory", "--module": "moduleDirectory",
"--out": "outDirectory", "--out": "outDirectory",
"--repository": "repository", "--repository": "repository",
@@ -43,12 +44,19 @@ export function parsePackModuleArguments(argv: readonly string[]): PackModuleOpt
} }
values.set(option, value); values.set(option, value);
} }
const required = ["moduleDirectory", "outDirectory", "sourceRevision", "target"] as const; const required = [
"licenseFile",
"moduleDirectory",
"outDirectory",
"sourceRevision",
"target",
] as const;
if (required.some((option) => !values.has(option))) { if (required.some((option) => !values.has(option))) {
throw new PackArgumentsError(USAGE); throw new PackArgumentsError(USAGE);
} }
const repository = values.get("repository"); const repository = values.get("repository");
return { return {
licenseFile: values.get("licenseFile") ?? "",
moduleDirectory: values.get("moduleDirectory") ?? "", moduleDirectory: values.get("moduleDirectory") ?? "",
outDirectory: values.get("outDirectory") ?? "", outDirectory: values.get("outDirectory") ?? "",
...(repository === undefined ? {} : { repository }), ...(repository === undefined ? {} : { repository }),
+36 -3
View File
@@ -3,17 +3,18 @@ import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "no
import { isBuiltin } from "node:module"; import { isBuiltin } from "node:module";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { type ModuleManifest, safeParseModuleManifest } from "@ekklesia/contracts";
import { import {
MODULE_PACKAGE_ANY_TARGET, MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT, MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_LICENSE_FILENAME,
MODULE_PACKAGE_METADATA_FILENAME, MODULE_PACKAGE_METADATA_FILENAME,
MODULE_PACKAGE_NOTICES_FILENAME, MODULE_PACKAGE_NOTICES_FILENAME,
type ModuleManifest,
modulePackageArchiveFileName, modulePackageArchiveFileName,
modulePackageRuntimePackage, modulePackageRuntimePackage,
safeParseModuleManifest, safeParseModulePackageLicenseExpression,
safeParseModulePackageMetadata, safeParseModulePackageMetadata,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts/module-package";
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build"; import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild"; import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, validRange } from "semver"; import { minVersion, satisfies, validRange } from "semver";
@@ -40,6 +41,8 @@ export type ModulePackErrorCode =
| "MODULE_PACK_IDENTITY_MISMATCH" | "MODULE_PACK_IDENTITY_MISMATCH"
| "MODULE_PACK_INCORPORATED_CONFLICT" | "MODULE_PACK_INCORPORATED_CONFLICT"
| "MODULE_PACK_LAYOUT_CONFLICT" | "MODULE_PACK_LAYOUT_CONFLICT"
| "MODULE_PACK_MODULE_LICENSE_FILE_INVALID"
| "MODULE_PACK_MODULE_LICENSE_INVALID"
| "MODULE_PACK_LICENCE_TEXT_MISSING" | "MODULE_PACK_LICENCE_TEXT_MISSING"
| "MODULE_PACK_LICENCE_UNKNOWN" | "MODULE_PACK_LICENCE_UNKNOWN"
| "MODULE_PACK_MANIFEST_INVALID" | "MODULE_PACK_MANIFEST_INVALID"
@@ -71,6 +74,10 @@ const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
MODULE_PACK_INCORPORATED_CONFLICT: MODULE_PACK_INCORPORATED_CONFLICT:
"The package bundles more than one version of an Ekklesia package.", "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_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: MODULE_PACK_LICENCE_TEXT_MISSING:
"A bundled package ships no licence text, and its licence has no complete standard text to supply.", "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.", 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 { export interface PackModuleOptions {
/** A built module directory: its manifest, package.json, and compiled entrypoints. */ /** A built module directory: its manifest, package.json, and compiled entrypoints. */
readonly moduleDirectory: string; 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. */ /** `any` only, in this release. */
readonly target: string; readonly target: string;
readonly outDirectory: string; readonly outDirectory: string;
@@ -377,6 +386,28 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
) { ) {
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH"); 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)); const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
if (validRange(nodeRange) === null) { if (validRange(nodeRange) === null) {
@@ -493,6 +524,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
} }
} }
add("ekklesia.module.json", manifestBytes); add("ekklesia.module.json", manifestBytes);
add(MODULE_PACKAGE_LICENSE_FILENAME, licenseBytes);
add( add(
"package.json", "package.json",
Buffer.from( Buffer.from(
@@ -522,6 +554,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
const metadata = { const metadata = {
packageFormat: MODULE_PACKAGE_FORMAT, packageFormat: MODULE_PACKAGE_FORMAT,
module: { id: manifest.id, version: manifest.version }, module: { id: manifest.id, version: manifest.version },
license,
target: MODULE_PACKAGE_ANY_TARGET, target: MODULE_PACKAGE_ANY_TARGET,
runtime: { node: nodeRange }, runtime: { node: nodeRange },
provenance: { provenance: {
+9
View File
@@ -13,11 +13,14 @@ describe("ekklesia-pack-module arguments", () => {
"any", "any",
"--out", "--out",
"dist/packages", "dist/packages",
"--license-file",
"LICENSE",
"--source-revision", "--source-revision",
revision, revision,
]), ]),
).toEqual({ ).toEqual({
moduleDirectory: "templates/module", moduleDirectory: "templates/module",
licenseFile: "LICENSE",
outDirectory: "dist/packages", outDirectory: "dist/packages",
sourceRevision: revision, sourceRevision: revision,
target: "any", target: "any",
@@ -33,6 +36,8 @@ describe("ekklesia-pack-module arguments", () => {
"any", "any",
"--out", "--out",
"out", "out",
"--license-file",
"LICENSE",
"--source-revision", "--source-revision",
revision, revision,
"--repository", "--repository",
@@ -44,6 +49,10 @@ describe("ekklesia-pack-module arguments", () => {
it.each([ it.each([
["no arguments", []], ["no arguments", []],
["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]], ["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]],
[
"a missing license file",
["--module", ".", "--target", "any", "--out", "o", "--source-revision", revision],
],
["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]], ["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]],
[ [
"an unknown option", "an unknown option",
+61 -2
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises"; import { readFile, symlink, writeFile } from "node:fs/promises";
import { isBuiltin } from "node:module"; import { isBuiltin } from "node:module";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -21,6 +21,7 @@ async function pack(options: FixtureModuleOptions = {}, overrides: Record<string
const moduleDirectory = await fixtureModule(options); const moduleDirectory = await fixtureModule(options);
const outDirectory = await scratchDirectory("ekklesia-pack-out-"); const outDirectory = await scratchDirectory("ekklesia-pack-out-");
const result = await packModule({ const result = await packModule({
licenseFile: path.join(moduleDirectory, "LICENSE"),
moduleDirectory, moduleDirectory,
nodeRange: ">=24.11.0 <25", nodeRange: ">=24.11.0 <25",
outDirectory, outDirectory,
@@ -61,6 +62,7 @@ describe("packModule: what a package contains (ADR 0049)", () => {
const { archive } = await pack({ withSchema: true }); const { archive } = await pack({ withSchema: true });
const files = [...fileEntries(archive).keys()].sort(); const files = [...fileEntries(archive).keys()].sort();
expect(files).toEqual([ expect(files).toEqual([
"example/LICENSE",
"example/THIRD_PARTY_NOTICES", "example/THIRD_PARTY_NOTICES",
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u), expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
"example/dist/schema/index.js", "example/dist/schema/index.js",
@@ -81,6 +83,62 @@ describe("packModule: what a package contains (ADR 0049)", () => {
); );
}); });
it("copies the explicit license bytes unchanged and records their source SPDX expression", async () => {
const licenseText = Buffer.from([0x4d, 0x49, 0x54, 0x0d, 0x0a, 0x00, 0xff]);
const { archive } = await pack({ license: "MIT OR Apache-2.0", licenseText });
const files = fileEntries(archive);
const packaged = files.get("example/LICENSE");
const metadata = JSON.parse(
files.get("example/ekklesia.package.json")?.toString("utf8") ?? "{}",
) as {
license?: string;
files?: { path: string; size: number; sha256: string }[];
};
expect(metadata.license).toBe("MIT OR Apache-2.0");
expect(packaged?.equals(licenseText)).toBe(true);
expect(metadata.files?.filter((file) => file.path === "LICENSE")).toEqual([
{
path: "LICENSE",
size: licenseText.length,
sha256: createHash("sha256").update(licenseText).digest("hex"),
},
]);
});
it.each([
["a missing declaration", undefined],
["an empty declaration", ""],
["UNLICENSED", "UNLICENSED"],
["legacy object metadata", { type: "MIT", url: "https://example.invalid/license" }],
["a malformed SPDX expression", "MIT OR"],
])("refuses %s", async (_label, license) => {
await packRejection({ license }, "MODULE_PACK_MODULE_LICENSE_INVALID");
});
it("requires a readable regular license-text input", async () => {
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: undefined,
});
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: path.join(await scratchDirectory("ekklesia-missing-license-"), "missing"),
});
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: await scratchDirectory("ekklesia-license-directory-"),
});
const emptyDirectory = await scratchDirectory("ekklesia-empty-license-");
await writeFile(path.join(emptyDirectory, "LICENSE"), "");
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: path.join(emptyDirectory, "LICENSE"),
});
const linkDirectory = await scratchDirectory("ekklesia-linked-license-");
await writeFile(path.join(linkDirectory, "real"), "MIT License\n");
await symlink(path.join(linkDirectory, "real"), path.join(linkDirectory, "LICENSE"));
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
licenseFile: path.join(linkDirectory, "LICENSE"),
});
});
it("generates the runtime package.json instead of copying the development one", async () => { it("generates the runtime package.json instead of copying the development one", async () => {
const { archive } = await pack(); const { archive } = await pack();
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8"); const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
@@ -420,11 +478,12 @@ describe("packModule: THIRD_PARTY_NOTICES", () => {
describe("packModule: deterministic archives", () => { describe("packModule: deterministic archives", () => {
it("produces byte-identical archives and digests for identical inputs", async () => { it("produces byte-identical archives and digests for identical inputs", async () => {
const moduleDirectory = await fixtureModule({ withSchema: true });
const runs = []; const runs = [];
for (let run = 0; run < 2; run += 1) { for (let run = 0; run < 2; run += 1) {
const moduleDirectory = await fixtureModule({ withSchema: true });
const outDirectory = await scratchDirectory("ekklesia-pack-out-"); const outDirectory = await scratchDirectory("ekklesia-pack-out-");
const result = await packModule({ const result = await packModule({
licenseFile: path.join(moduleDirectory, "LICENSE"),
moduleDirectory, moduleDirectory,
nodeRange: ">=24.11.0 <25", nodeRange: ">=24.11.0 <25",
outDirectory, outDirectory,
@@ -24,6 +24,8 @@ export interface FakePackage {
export interface FixtureModuleOptions { export interface FixtureModuleOptions {
readonly databaseAbi?: 1; readonly databaseAbi?: 1;
readonly id?: string; readonly id?: string;
readonly license?: unknown;
readonly licenseText?: string | Uint8Array;
readonly version?: string; readonly version?: string;
readonly serverSource?: string; readonly serverSource?: string;
readonly withModuleDatabase?: boolean; readonly withModuleDatabase?: boolean;
@@ -114,6 +116,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
{ {
name: `@test/module-${id}`, name: `@test/module-${id}`,
version, version,
license: Object.hasOwn(options, "license") ? options.license : "MIT",
private: true, private: true,
type: "module", type: "module",
scripts: { build: "tsc" }, scripts: { build: "tsc" },
@@ -130,6 +133,10 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
2, 2,
)}\n`, )}\n`,
); );
await write(
path.join(moduleDirectory, "LICENSE"),
options.licenseText ?? "MIT License\n\nCopyright (c) 2026 Example Module\n",
);
await write( await write(
path.join(moduleDirectory, "dist/server/index.js"), path.join(moduleDirectory, "dist/server/index.js"),
options.serverSource ?? defaultServer(id, version, options.databaseAbi), options.serverSource ?? defaultServer(id, version, options.databaseAbi),
@@ -68,6 +68,7 @@ async function treeListing(directory: string): Promise<readonly string[]> {
async function packTemplate() { async function packTemplate() {
const outDirectory = await scratchDirectory("ekklesia-template-package-"); const outDirectory = await scratchDirectory("ekklesia-template-package-");
const result = await packModule({ const result = await packModule({
licenseFile: path.join(repositoryRoot, "LICENSE"),
moduleDirectory: await templateDirectory(), moduleDirectory: await templateDirectory(),
outDirectory, outDirectory,
sourceRevision: SOURCE_REVISION, sourceRevision: SOURCE_REVISION,
@@ -104,8 +105,19 @@ describe("the module template as a Module Package v1", () => {
const files = fileEntries(archive); const files = fileEntries(archive);
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? ""); 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 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(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.transitional).toBeUndefined();
expect(metadata.incorporates).toMatchObject({ expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String), "@ekklesia/module-database": expect.any(String),
@@ -209,6 +221,12 @@ describe("the module template as a Module Package v1", () => {
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"), 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")); const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
expect(createHash("sha256").update(licence).digest("hex")).toBe( expect(createHash("sha256").update(licence).digest("hex")).toBe(
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
+29
View File
@@ -155,6 +155,9 @@ importers:
semver: semver:
specifier: 7.8.5 specifier: 7.8.5
version: 7.8.5 version: 7.8.5
spdx-expression-parse:
specifier: 5.0.0
version: 5.0.0
zod: zod:
specifier: 4.4.3 specifier: 4.4.3
version: 4.4.3 version: 4.4.3
@@ -168,6 +171,9 @@ importers:
'@types/semver': '@types/semver':
specifier: 7.7.1 specifier: 7.7.1
version: 7.7.1 version: 7.7.1
'@types/spdx-expression-parse':
specifier: 4.0.0
version: 4.0.0
typescript: typescript:
specifier: 7.0.2 specifier: 7.0.2
version: 7.0.2 version: 7.0.2
@@ -1457,6 +1463,9 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==} resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
'@types/[email protected]':
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -2256,6 +2265,15 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
[email protected]:
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
[email protected]:
resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
@@ -3325,6 +3343,8 @@ snapshots:
'@types/node': 24.13.3 '@types/node': 24.13.3
'@types/nodemailer': 8.0.1 '@types/nodemailer': 8.0.1
'@types/[email protected]': {}
'@types/[email protected]': {} '@types/[email protected]': {}
'@typescript/[email protected]': '@typescript/[email protected]':
@@ -3962,6 +3982,15 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.24
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+1 -1
View File
@@ -25,7 +25,7 @@ RUN pnpm exec turbo run build --filter=@ekklesia/module-template --filter=@ekkle
ARG SOURCE_REVISION ARG SOURCE_REVISION
RUN test -n "$SOURCE_REVISION" && \ RUN test -n "$SOURCE_REVISION" && \
node packages/module-pack/bin/ekklesia-pack-module.mjs --module templates/module --target any \ node packages/module-pack/bin/ekklesia-pack-module.mjs --module templates/module --target any \
--out /package --source-revision "$SOURCE_REVISION" --out /package --license-file LICENSE --source-revision "$SOURCE_REVISION"
FROM scratch AS package FROM scratch AS package
COPY --from=build /package/ / COPY --from=build /package/ /
+7 -1
View File
@@ -65,6 +65,7 @@ bundles the backend into one deterministic Ekklesia Module Package (ADR 0049):
```bash ```bash
pnpm modules:pack --module templates/module --target any --out <directory> \ pnpm modules:pack --module templates/module --target any --out <directory> \
--license-file LICENSE \
--source-revision "$(git rev-parse HEAD)" --source-revision "$(git rev-parse HEAD)"
``` ```
@@ -89,4 +90,9 @@ classification or approval.
The template's `package.json` declares `"license": "AGPL-3.0-or-later"`, the license of Ekklesia The template's `package.json` declares `"license": "AGPL-3.0-or-later"`, the license of Ekklesia
and of official Ekklesia modules (see the repository [README](../../README.md#license) and and of official Ekklesia modules (see the repository [README](../../README.md#license) and
[LICENSE](../../LICENSE)). A module started from the template begins with that declaration; an [LICENSE](../../LICENSE)). A module started from the template begins with that declaration; an
official Ekklesia module keeps it. official Ekklesia module keeps it. The packaging command explicitly supplies the repository's
canonical `LICENSE`, avoiding a duplicate file in this nested template. Its exact bytes become the
package-root `LICENSE`, while `ekklesia.package.json.license` receives the source declaration.
Standalone modules should keep their own license file and pass its path explicitly. The packager
does not infer or download license material, and `THIRD_PARTY_NOTICES` remains only for bundled
third-party dependencies.