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 `-[-]` 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 -, 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; export type ModulePackageFile = z.output; export type ModulePackageTarget = z.output; 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; 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`; }