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
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]>
258 lines
10 KiB
TypeScript
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`;
|
|
}
|