CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s
Makes the public module-development package set usable from a module repository outside the monorepo. Nothing is published. - Curated @ekklesia/module-sdk: an explicit author API, no contracts barrel, no identity/authorization internals, no Drizzle and no legacy ModuleDatabase type. defineModuleRuntime returns the SDK's own named interfaces so a module's emitted declarations never reach into @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and defineModuleSchemaMetadata live in @ekklesia/module-database. - Drizzle: no longer a dependency of module-sdk or contracts. Core keeps the legacy Drizzle schema validator (moved to core/src/modules) and its legacy runtime support; drizzle-orm is an optional peer only. - React is a peer (plus devDependency) of @ekklesia/ui; the boundary checker allows exactly that consumer-selected peer. - @ekklesia/typescript-config is publishable; module-ui-host is private. Every public package carries "ekklesia.distribution": "module-development" and no monorepo-root prepack. - module-pack reads the Node.js range from the module's own engines.node and recognizes Ekklesia packages by name plus that marker, so registry-installed packages are incorporated, SDK-compatibility checked, and never credited as third-party. - scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs and inspects the nine public tarballs; scripts/test-standalone-module.sh (pnpm test:standalone-module, new CI job) installs them into a fixture module outside the repository and proves it typechecks, builds, packages, and verifies. - Public-package boundary tests and module-development docs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
|
|
//
|
|
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
|
|
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
|
|
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
|
|
// Whatever the module's top-level code does happens here, in a process with no database URL, no
|
|
// secret, and no platform state, and that is discarded as soon as it reports.
|
|
//
|
|
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
|
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
|
//
|
|
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
|
|
// compiled file in the image. Its one relative import names the source file's own extension, which
|
|
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
parseModuleRuntimeDefinition,
|
|
parseModuleSchemaMetadataDefinition,
|
|
} from "@ekklesia/contracts";
|
|
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
|
|
|
|
interface ProbeRequest {
|
|
readonly databaseAbi?: 1;
|
|
readonly moduleId: string;
|
|
readonly version: string;
|
|
readonly entrypoints: Readonly<Record<string, string>>;
|
|
}
|
|
|
|
interface ProbeFailure {
|
|
readonly role: string;
|
|
readonly reason: "contract" | "export" | "identity" | "load";
|
|
}
|
|
|
|
function defaultExport(namespace: unknown): unknown {
|
|
return typeof namespace === "object" && namespace !== null && "default" in namespace
|
|
? (namespace as { default: unknown }).default
|
|
: undefined;
|
|
}
|
|
|
|
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
|
|
const failures: ProbeFailure[] = [];
|
|
for (const role of Object.keys(request.entrypoints).sort()) {
|
|
const entrypoint = request.entrypoints[role];
|
|
if (entrypoint === undefined) {
|
|
continue;
|
|
}
|
|
let namespace: unknown;
|
|
try {
|
|
namespace = await import(pathToFileURL(entrypoint).href);
|
|
} catch {
|
|
failures.push({ reason: "load", role });
|
|
continue;
|
|
}
|
|
try {
|
|
if (role === "server") {
|
|
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
|
|
if (definition.id !== request.moduleId || definition.version !== request.version) {
|
|
failures.push({ reason: "identity", role });
|
|
}
|
|
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
|
|
failures.push({ reason: "contract", role });
|
|
}
|
|
} else if (role === "schema") {
|
|
const definition =
|
|
request.databaseAbi === 1
|
|
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
|
|
: parseModuleSchemaDefinition(defaultExport(namespace));
|
|
if (definition.moduleId !== request.moduleId) {
|
|
failures.push({ reason: "identity", role });
|
|
}
|
|
}
|
|
} catch {
|
|
failures.push({ reason: "export", role });
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function report(message: unknown): void {
|
|
if (typeof process.send !== "function") {
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
process.send(message, () => {
|
|
// A module may leave timers or handles behind; the probe's work is done either way.
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
let request: ProbeRequest | undefined;
|
|
try {
|
|
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
|
|
} catch {
|
|
report({ failures: [{ reason: "load", role: "request" }] });
|
|
}
|
|
if (request !== undefined) {
|
|
probe(request).then(
|
|
(failures) => report({ failures }),
|
|
() => report({ failures: [{ reason: "load", role: "probe" }] }),
|
|
);
|
|
}
|