Files
Ekklesia/packages/contracts/src/module-package.ts
ekklesiaandClaude Opus 5.5 44d385cd7f
CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)
Implements ADR 0049's deferred native target and ADR 0052's reviewed
license supplements. The proof case is a real module using sharp.
packageFormat stays 1, and `any` is unchanged.

Packager (@ekklesia/module-pack):
- --target linux-x64-glibc. Native or platform-specific packages, found by
  the same generic rule that refuses them in `any`, stay outside the bundle.
- Each native package's production closure is walked through its own
  dependencies, installed optionalDependencies that match the target, and
  required peers. It is copied as real files flat under node_modules/<name>,
  with no links, dev dependencies, declarations, source maps, or other
  platforms' packages. Missing or foreign-platform required dependencies,
  version conflicts, links, foreign machines, and Node.js range conflicts
  fail closed.
- runtime.nodeApi, nodeAbi, and libcVersion are read from the binaries by a
  small ELF reader: the addon's node_api_module_get_api_version_v1 and the
  newest GLIBC_ symbol version needed. No shelling out. A native target with
  no addon is refused.
- ADR 0052 supplements: package-provided material stays primary. An exact
  match on name, version, and ekklesia-content-v1 digest (sorted normalized
  paths with each file's size and SHA-256; no metadata) adds labelled
  material after the package's own. Any mismatch, a declared-licence
  conflict, or a malformed or unreviewed supplement file fails. Missing
  material with no supplement still fails.

Supplement for @img/[email protected]:
- 184 components and 143 unique texts, assembled from the exact upstream
  sources sharp-libvips v1.3.3 builds linux-x64 from: 27 C/C++ libraries,
  glib's gvdb, librsvg's Rust crates (reproduced with cargo tree), the Rust
  standard library at the rustc commit recorded in the binary, plus the
  LGPL-3.0, GPL-3.0, and MPL-2.0 texts. Each source is recorded with its
  SHA-256 in PROVENANCE.md.
- It pins digest ekklesia-content-v1:sha256:50647b0b…, which is identical
  from the installed copy and from the registry tarball.
- LGPL/MPL source-availability determinations stay open, as ADR 0052
  states. Nothing claims they are met.

Verifier (Core):
- linux-x64-glibc is accepted only on a host whose platform, arch, glibc
  family and version, and Node-API version (from Node itself) match.
- Every ELF file must be x86-64; every .node must be an ELF object.
- `any` packages now also refuse node_modules/ and ELF files.
- Contract: native metadata must state nodeApi or nodeAbi, and Linux targets
  libcVersion (ADR 0049).

Proof:
- A checked-in AGPL native fixture packs byte-identically from unrelated
  copies (8.86 MB) and verifies with Core's verifier.
- Extracted elsewhere, it runs a sharp resize and PNG round trip in an
  empty-environment process. Every resolution stays in the package, and
  only the package's addon and libvips plus host system libraries load.

Signage is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 20:16:53 -05:00

258 lines
10 KiB
TypeScript

import { z } from "zod";
import parseSpdxExpression from "spdx-expression-parse";
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_LICENSE_FILENAME = "LICENSE";
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";
/** 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`
* 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.
*
* For a manifest-v8 module that omits `database`, `databaseRuntime` records the Drizzle version
* bundled with the module's backend because Core runs that legacy module's queries through its own
* Drizzle instance over the module's table objects. ABI-v1 packages must omit this fact: their ORM
* stays module-side and Core receives only the stable SQL ABI.
*/
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 }),
/** The module's authoritative package-release license declaration (ADR 0051). */
license: modulePackageLicenseExpressionSchema,
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) => {
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 (
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"],
});
}
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
// ADR 0049: a native package states what its addons need from Node.js and, on Linux, the
// minimum C library version.
if (metadata.runtime.nodeApi === undefined && metadata.runtime.nodeAbi === undefined) {
context.addIssue({
code: "custom",
message: "A native package states the Node-API version or Node.js ABI its addons need.",
path: ["runtime"],
});
}
if (metadata.target.startsWith("linux-") && metadata.runtime.libcVersion === undefined) {
context.addIssue({
code: "custom",
message: "A native Linux package states the minimum C library version it needs.",
path: ["runtime", "libcVersion"],
});
}
}
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 safeParseModulePackageLicenseExpression(input: unknown) {
return modulePackageLicenseExpressionSchema.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`;
}