Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
# @suaveplan/api-manifest - Zod manifest schema, parser, cross-field validators, dep-graph resolver, capability resolver, and canonical hasher for Suaveplan API plugin system
Stage-1 contract package of the Suaveplan API plugin system: Zod schemas, parser, cross-field validators, dependency-graph resolver, capability resolver, and canonical hasher for the `plugin.json` manifest documented in
`docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md` §3.
This package is the single source of truth for the declarative half of every plugin contract. Every consumer (host loader, admin API, CLI scaffolder, OpenAPI emitter, `definePlugin` typings) imports from here — the schema does not live in any single host implementation.
<!-- begin:auto-docs -->
## Documentation tree
- [capability-resolver](./src/capability-resolver/capability-resolver.md) — Decide whether a plugin can boot against the host's registered capability set. Missing required capabilities block reso…
- [capability-resolver](./src/capability-resolver/capability-resolver.md) — Decide whether a plugin can boot against the host's registered capability set. Missing required capabilities block reso…
- [dep-graph](./src/dep-graph/dep-graph.md) — Topologically order a set of plugin manifests so each plugin's `dependsOn` entries precede it. Deterministic — lexicogr…
- [dep-graph](./src/dep-graph/dep-graph.md) — Topologically order a set of plugin manifests so each plugin's `dependsOn` entries precede it. Deterministic — lexicogr…
- [semver](./src/dep-graph/semver.md) — Small in-house semver-range matcher used by the dep-graph and capability resolvers. Covers a deliberately narrow subset…
- [error-codes](./src/error-codes/error-codes.md) — Frozen `MANIFEST.*` error-code registry plus the concrete `SuaveplanError` subclass thrown by every public path in this…
- [error-codes](./src/error-codes/error-codes.md) — Frozen `MANIFEST.*` error-code registry plus the concrete `SuaveplanError` subclass thrown by every public path in this…
- [hash](./src/hash/hash.md) — Compute `sha256:<hex>` over the canonical-JSON encoding of a manifest. Used by `RollbackJournal` entries and the plugin…
- [canonical-json](./src/hash/canonical-json.md) — Canonical JSON encoder used by `hashManifest`. Object keys are sorted lexicographically at every depth; array order is…
- [hash](./src/hash/hash.md) — Compute `sha256:<hex>` over the canonical-JSON encoding of a manifest. Used by `RollbackJournal` entries and the plugin…
- [parse](./src/parse/parse.md) — Two top-level entry points for converting an unknown JSON value into a typed `PluginManifest`. `parseManifest` is the t…
- [parse](./src/parse/parse.md) — Two top-level entry points for converting an unknown JSON value into a typed `PluginManifest`. `parseManifest` is the t…
- [compat](./src/schema/compat.md) — Validates the compatibility block of a plugin manifest: the `apiHost` semver range and the two capability arrays (`requ…
- [config](./src/schema/config.md) — Plugin-level config block. `schemaPath` points at a JSON Schema the host validates user-supplied config against at inst…
- [database](./src/schema/database.md) — Database-wiring block. Pins whether the plugin needs a database connection, which engines it supports, its current sche…
- [depends-on](./src/schema/depends-on.md) — Map of plugin-name → semver range used by the dep-graph resolver. Names follow the same npm-package convention as `iden…
- [errors](./src/schema/errors.md) — Plugin-error status-map block. Maps `PLUGIN.<DOMAIN>.<REASON>` codes to HTTP status numbers and optional redaction path…
- [events](./src/schema/events.md) — Event-bus topology declaration. Lists the subjects this plugin publishes and the subjects it subscribes to. Subjects fo…
- [graphql](./src/schema/graphql.md) — Optional GraphQL contribution block. When `contributes` is `true`, the host loads the SDL at `types` (path relative to…
- [identity](./src/schema/identity.md) — Zod schema for the identity fields of a plugin manifest — the keys that uniquely identify a plugin in the registry and…
- [isolation](./src/schema/isolation.md) — Two Zod enum schemas covering the orthogonal isolation and trust axes of the manifest. Isolation determines how the hos…
- [jobs](./src/schema/jobs.md) — Background-job block: cron schedules and queued workers. Handlers live in the code half of the manifest; this block is…
- [openapi](./src/schema/openapi.md) — OpenAPI contribution block. When `contributes: true` the host emits a per-plugin OpenAPI document by introspecting the…
- [permissions](./src/schema/permissions.md) — Zod schemas for the sandbox permissions block. `permissions.fs` and `permissions.fetch` are host-proxied allowlists hon…
- [plugin-manifest](./src/schema/plugin-manifest.md) — Composite Zod schema joining every per-key sub-schema into a single strict-mode object. The inferred `PluginManifest` t…
- [routes](./src/schema/routes.md) — Validates the declarative HTTP-routes block. Carries the route `prefix` plus an optional `publicRoutes` array — routes…
- [sagas](./src/schema/sagas.md) — Saga ownership + participation block. `owns` lists the sagas this plugin orchestrates (each with a non-empty step list)…
- [scopes](./src/schema/scopes.md) — AuthZ scope declarations. Each entry pins a `resource:action` name and an optional human-readable description. The host…
- [secrets](./src/schema/secrets.md) — Declares the named secrets the plugin needs at boot. Names use SCREAMING_SNAKE_CASE so they map cleanly onto environmen…
- [sse](./src/schema/sse.md) — Server-Sent-Events endpoint declarations. Mirrors the WebSocket block's shape minus the heartbeat/connection cap — SSE…
- [telemetry](./src/schema/telemetry.md) — Telemetry-instrument declarations. The host pre-registers every named counter, histogram, and gauge so plugin code can…
- [two-phase-commit](./src/schema/two-phase-commit.md) — Two-phase-commit participation declaration. A plugin sets `participates: true` to opt into the host's 2PC coordinator;…
- [uninstall](./src/schema/uninstall.md) — Uninstall data-handling policy. Three modes: `archive` (move rows to a prefixed shadow table so they survive removal),…
- [websockets](./src/schema/websockets.md) — Validates the WebSocket endpoint declarations. Each entry pins a path, an optional auth scope, the set of subjects the…
- [testing](./src/testing/conformance.md) — Reusable conformance harness for downstream tooling (admin CLI, IDE plugin) that ships its own `parseManifest` implemen…
- [conformance](./src/testing/conformance.md) — Reusable conformance harness for downstream tooling (admin CLI, IDE plugin) that ships its own `parseManifest` implemen…
- [validate-error-status-map-shape](./src/validators/validate-error-status-map-shape.md) — Defence-in-depth re-check of the `errors.statusMap` shape. The Zod schema already rejects malformed entries; this valid…
- [validate-openapi-requires-graphql-or-routes](./src/validators/validate-openapi-requires-graphql-or-routes.md) — Rejects manifests that declare an OpenAPI contribution but expose neither HTTP routes nor a GraphQL contribution. The O…
- [validate-permissions-when-sandboxed](./src/validators/validate-permissions-when-sandboxed.md) — Enforces that a `sandboxed`-tier plugin declares `permissions.budgets`. Without budgets the host can't enforce CPU/memo…
- [validate-saga-ownership](./src/validators/validate-saga-ownership.md) — Cross-field validator that rejects sagas whose step list references a plugin neither owned by this manifest nor declare…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-manifest
```
## Subpath exports
Each concern is a separate subpath so consumers tree-shake to exactly what they use.
- [`./schema`](./src/schema/plugin-manifest.md) — `pluginManifestSchema`, the composite Zod schema, plus every per-key sub-schema.
- [`./parse`](./src/parse/parse.md) — `parseManifest` and `parseManifestSafe`.
- [`./validators`](./src/validators/validate-saga-ownership.md) — four cross-field validators that Zod alone can't express.
- [`./dep-graph`](./src/dep-graph/dep-graph.md) — `resolveInstallOrder` and the in-house `satisfies` semver matcher.
- [`./capability-resolver`](./src/capability-resolver/capability-resolver.md) — `resolveCapabilities`.
- [`./hash`](./src/hash/hash.md) — `hashManifest` and the underlying `canonicalJson` encoder.
- [`./error-codes`](./src/error-codes/error-codes.md) — `MANIFEST_CODES` registry and `ManifestError`.
- [`./testing`](./src/testing/conformance.md) — `runManifestConformance` harness and fixtures.
## Modules
### Schema
- [`identity`](./src/schema/identity.md), [`compat`](./src/schema/compat.md), [`isolation`](./src/schema/isolation.md), [`permissions`](./src/schema/permissions.md)
- [`depends-on`](./src/schema/depends-on.md), [`routes`](./src/schema/routes.md), [`graphql`](./src/schema/graphql.md)
- [`websockets`](./src/schema/websockets.md), [`sse`](./src/schema/sse.md), [`sagas`](./src/schema/sagas.md), [`two-phase-commit`](./src/schema/two-phase-commit.md)
- [`database`](./src/schema/database.md), [`jobs`](./src/schema/jobs.md), [`events`](./src/schema/events.md)
- [`secrets`](./src/schema/secrets.md), [`config`](./src/schema/config.md), [`scopes`](./src/schema/scopes.md)
- [`telemetry`](./src/schema/telemetry.md), [`openapi`](./src/schema/openapi.md), [`errors`](./src/schema/errors.md), [`uninstall`](./src/schema/uninstall.md)
- [`plugin-manifest`](./src/schema/plugin-manifest.md) — the composite schema.
### Parse, validators, graph, capability, hash, testing
- [`parse`](./src/parse/parse.md)
- [`validate-saga-ownership`](./src/validators/validate-saga-ownership.md)
- [`validate-permissions-when-sandboxed`](./src/validators/validate-permissions-when-sandboxed.md)
- [`validate-openapi-requires-graphql-or-routes`](./src/validators/validate-openapi-requires-graphql-or-routes.md)
- [`validate-error-status-map-shape`](./src/validators/validate-error-status-map-shape.md)
- [`dep-graph`](./src/dep-graph/dep-graph.md), [`semver`](./src/dep-graph/semver.md)
- [`capability-resolver`](./src/capability-resolver/capability-resolver.md)
- [`hash`](./src/hash/hash.md), [`canonical-json`](./src/hash/canonical-json.md)
- [`testing`](./src/testing/conformance.md)
- [`error-codes`](./src/error-codes/error-codes.md)
## Error codes
Every code is namespaced under `MANIFEST.*` and produced by `defineCodes`. The eleven codes — `PARSE_FAILED`, `DEPENDENCY_CYCLE`, `MISSING_DEPENDENCY`, `DEPENDENCY_VERSION_MISMATCH`, `CAPABILITY_REQUIRED_MISSING`, `CAPABILITY_VERSION_MISMATCH`, `OPTIONAL_CAPABILITY_MISSING`, `INVALID_SAGA_OWNERSHIP`, `SANDBOX_REQUIRES_BUDGETS`, `INVALID_ERROR_STATUS_MAP`, `OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES` — are pinned by a spec-conformance test and each has at least one production-path reference.
## Dependencies
- `@suaveplan/api-types` — the type-level view of the manifest (zero runtime).
- `@suaveplan/error` — `SuaveplanError`, `defineCodes`.
- `@suaveplan/types` — shared logger/clock/id-generator contracts.
- `zod` — runtime schema engine.
## License
MIT

View File

@@ -0,0 +1,107 @@
{
"name": "@suaveplan/api-manifest",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Zod manifest schema, parser, cross-field validators, dep-graph resolver, capability resolver, and canonical hasher for Suaveplan API plugin foundation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./schema": {
"import": {
"types": "./dist/schema/index.d.ts",
"default": "./dist/schema/index.js"
}
},
"./parse": {
"import": {
"types": "./dist/parse/index.d.ts",
"default": "./dist/parse/index.js"
}
},
"./validators": {
"import": {
"types": "./dist/validators/index.d.ts",
"default": "./dist/validators/index.js"
}
},
"./dep-graph": {
"import": {
"types": "./dist/dep-graph/index.d.ts",
"default": "./dist/dep-graph/index.js"
}
},
"./capability-resolver": {
"import": {
"types": "./dist/capability-resolver/index.d.ts",
"default": "./dist/capability-resolver/index.js"
}
},
"./hash": {
"import": {
"types": "./dist/hash/index.d.ts",
"default": "./dist/hash/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,57 @@
# `resolveCapabilities`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Decide whether a plugin can boot against the host's registered capability set. Missing required capabilities block resolution; missing optional capabilities surface as warnings; version mismatches against the host's adapter version block resolution.
## Features
- Discriminated result type — `{ ok: true; warnings }` or `{ ok: false; error }`
- Optional `versionRanges` map drives per-capability semver checks
- Warnings carry the same code surface as errors
- Returns the full warning list (every missing optional, not just the first)
## Basic Example
```ts
import { resolveCapabilities } from "@suaveplan/api-manifest/capability-resolver";
resolveCapabilities({
manifest,
registered: [{ id: "db.transactions", version: "1.4.0" }],
});
```
## Advanced Example
```ts
import { resolveCapabilities, type CapabilityDescriptor } from "@suaveplan/api-manifest/capability-resolver";
const registered: CapabilityDescriptor[] = host.listCapabilities();
const result = resolveCapabilities({
manifest,
registered,
versionRanges: { "db.transactions": "^1" },
});
if (!result.ok) throw result.error;
for (const w of result.warnings) logger.warn(w.code, w.details);
```
## API Reference
- `resolveCapabilities(input: ResolveCapabilitiesInput): CapabilityResolution`
- Failure codes: `CAPABILITY_REQUIRED_MISSING`, `CAPABILITY_VERSION_MISMATCH`.
- Warning code: `OPTIONAL_CAPABILITY_MISSING`.
## Implementation Notes
Version ranges are supplied by the caller rather than the manifest because the manifest declares which capabilities it needs, not which versions — the host's compat policy decides the range. This keeps the manifest forward-compatible across host upgrades; only the host needs to be updated when a capability's wire format changes. Warnings are kept distinct from errors so the host can log them and continue boot, which is the whole point of marking a capability optional.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import {
type CapabilityDescriptor,
resolveCapabilities,
} from "./capability-resolver.js";
const empty: readonly CapabilityDescriptor[] = [];
function withCaps(
req: readonly string[] = [],
opt: readonly string[] = []
): PluginManifest {
return {
...MINIMAL_FIXTURE,
requiredCapabilities: req,
optionalCapabilities: opt,
};
}
describe("resolveCapabilities", () => {
it("missing required capability blocks resolution", {
timeout: 5000,
}, () => {
const r = resolveCapabilities({
manifest: withCaps(["db"]),
registered: empty,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(
MANIFEST_CODES.CAPABILITY_REQUIRED_MISSING
);
}
});
it("missing optional surfaces as warning", { timeout: 5000 }, () => {
const r = resolveCapabilities({
manifest: withCaps([], ["secrets.bun"]),
registered: empty,
});
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.warnings.length).toBe(1);
expect(r.warnings[0]?.code).toBe(
MANIFEST_CODES.OPTIONAL_CAPABILITY_MISSING
);
expect(r.warnings[0]?.details["id"]).toBe("secrets.bun");
}
});
it("ok when required is present and no optional", { timeout: 5000 }, () => {
const r = resolveCapabilities({
manifest: withCaps(["db"]),
registered: [{ id: "db", version: "1.0.0" }],
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.warnings.length).toBe(0);
});
it("version range matched against registered version", {
timeout: 5000,
}, () => {
const r = resolveCapabilities({
manifest: withCaps(["db"]),
registered: [{ id: "db", version: "1.4.0" }],
versionRanges: { db: "^1" },
});
expect(r.ok).toBe(true);
});
it("version mismatch surfaces error", { timeout: 5000 }, () => {
const r = resolveCapabilities({
manifest: withCaps(["db"]),
registered: [{ id: "db", version: "1.4.0" }],
versionRanges: { db: "^2" },
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(
MANIFEST_CODES.CAPABILITY_VERSION_MISMATCH
);
}
});
it("unparseable range counts as mismatch", { timeout: 5000 }, () => {
const r = resolveCapabilities({
manifest: withCaps(["db"]),
registered: [{ id: "db", version: "1.4.0" }],
versionRanges: { db: ">=1 <2" },
});
expect(r.ok).toBe(false);
});
it("absent required/optional arrays parse cleanly", {
timeout: 5000,
}, () => {
const r = resolveCapabilities({
manifest: MINIMAL_FIXTURE,
registered: empty,
});
expect(r.ok).toBe(true);
});
it("present optional that exists yields no warning", {
timeout: 5000,
}, () => {
const r = resolveCapabilities({
manifest: withCaps([], ["secrets.bun"]),
registered: [{ id: "secrets.bun", version: "1.0.0" }],
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.warnings.length).toBe(0);
});
});

View File

@@ -0,0 +1,104 @@
import { satisfies } from "../dep-graph/semver.js";
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
/**
* Capability descriptor the host registers. The `version` is the
* concrete adapter version implementing the capability id (e.g.
* `db.transactions` → `1.4.0`). Manifests pin compat via
* `compat.apiHost` and the `requiredCapabilities` / `optionalCapabilities`
* arrays referenced here.
*/
export interface CapabilityDescriptor {
readonly id: string;
readonly version: string;
}
export interface ResolveCapabilitiesInput {
readonly manifest: PluginManifest;
readonly registered: readonly CapabilityDescriptor[];
/** Optional version-range map: `capId -> semver range`. */
readonly versionRanges?: Readonly<Record<string, string>>;
}
/** Non-blocking warning surfaced by the resolver. */
export interface CapabilityWarning {
readonly code: string;
readonly details: Readonly<Record<string, unknown>>;
}
export type CapabilityResolution =
| { readonly ok: true; readonly warnings: readonly CapabilityWarning[] }
| { readonly ok: false; readonly error: ManifestError };
/**
* Resolve capabilities for a manifest against the host's registered
* adapter set.
*
* - Missing required → `MANIFEST.CAPABILITY_REQUIRED_MISSING`.
* - Version mismatch (when `versionRanges[id]` supplied) →
* `MANIFEST.CAPABILITY_VERSION_MISMATCH`.
* - Missing optional → warning with code
* `MANIFEST.OPTIONAL_CAPABILITY_MISSING` (resolution still succeeds).
*/
export function resolveCapabilities(
input: ResolveCapabilitiesInput
): CapabilityResolution {
const { manifest, registered, versionRanges = {} } = input;
const byId = new Map<string, string>();
for (const r of registered) byId.set(r.id, r.version);
const required = manifest.requiredCapabilities ?? [];
const missing: string[] = [];
const mismatched: { id: string; requested: string; actual: string }[] = [];
for (const id of required) {
const have = byId.get(id);
if (have === undefined) {
missing.push(id);
continue;
}
const range = versionRanges[id];
if (range && !safeSatisfies(have, range)) {
mismatched.push({ id, requested: range, actual: have });
}
}
if (missing.length > 0) {
return {
ok: false,
error: new ManifestError(
MANIFEST_CODES.CAPABILITY_REQUIRED_MISSING,
"manifest.capability_required_missing",
{ context: { missing } }
),
};
}
if (mismatched.length > 0) {
return {
ok: false,
error: new ManifestError(
MANIFEST_CODES.CAPABILITY_VERSION_MISMATCH,
"manifest.capability_version_mismatch",
{ context: { mismatched } }
),
};
}
const warnings: CapabilityWarning[] = [];
const optional = manifest.optionalCapabilities ?? [];
for (const id of optional) {
if (!byId.has(id)) {
warnings.push({
code: MANIFEST_CODES.OPTIONAL_CAPABILITY_MISSING,
details: { id },
});
}
}
return { ok: true, warnings };
}
function safeSatisfies(version: string, range: string): boolean {
try {
return satisfies(version, range);
} catch {
return false;
}
}

View File

@@ -0,0 +1,7 @@
export {
type CapabilityDescriptor,
type CapabilityResolution,
type CapabilityWarning,
type ResolveCapabilitiesInput,
resolveCapabilities,
} from "./capability-resolver.js";

View File

@@ -0,0 +1,53 @@
# `resolveInstallOrder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Topologically order a set of plugin manifests so each plugin's `dependsOn` entries precede it. Deterministic — lexicographic tie-breaking guarantees identical output across input shuffles. Throws on cycles, missing deps, and version mismatches with stable codes that admin tooling renders.
## Features
- Kahn's algorithm with sorted ready-queue for deterministic output
- Cycle detection with the cycle path surfaced on `context.path`
- Missing-dep + duplicate-name detection
- Semver-range matching via the in-house `satisfies()` against actual versions
## Basic Example
```ts
import { resolveInstallOrder } from "@suaveplan/api-manifest/dep-graph";
const order = resolveInstallOrder([a, b, c]);
```
## Advanced Example
```ts
import { resolveInstallOrder, ManifestError, MANIFEST_CODES } from "@suaveplan/api-manifest";
try {
const order = resolveInstallOrder(manifests);
for (const m of order) installPlugin(m);
} catch (e) {
if (e instanceof ManifestError && e.code === MANIFEST_CODES.DEPENDENCY_CYCLE) {
console.error("cycle:", e.context?.path);
}
}
```
## API Reference
- `resolveInstallOrder(manifests: readonly PluginManifest[]): readonly PluginManifest[]`
- Failure codes: `DEPENDENCY_CYCLE`, `MISSING_DEPENDENCY`, `DEPENDENCY_VERSION_MISMATCH`.
## Implementation Notes
Determinism matters because the install order is part of the audit trail — a non-deterministic resolver would make `manifestHash` collisions between runs misleading. The cycle finder is a depth-first walk over the stuck set (the manifests whose in-degree never hit zero); it returns a path with head=tail so the consumer sees the cycle as a closed loop. Duplicate plugin names (same `name` field twice) surface as `MISSING_DEPENDENCY` because the second occurrence is treated as ambiguous-target.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import { resolveInstallOrder } from "./dep-graph.js";
function mk(
name: string,
version: string,
deps: Record<string, string> = {}
): PluginManifest {
return { ...MINIMAL_FIXTURE, name, version, dependsOn: deps };
}
describe("resolveInstallOrder", () => {
it("orders dependencies before dependents", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0", { a: "^1" });
const c = mk("c", "1.0.0", { b: "^1" });
const order = resolveInstallOrder([c, a, b]).map((m) => m.name);
expect(order).toEqual(["a", "b", "c"]);
});
it("order is deterministic across shuffles", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0");
const c = mk("c", "1.0.0", { a: "^1", b: "^1" });
const d = mk("d", "1.0.0", { c: "^1" });
const sets = [
[a, b, c, d],
[d, c, b, a],
[b, a, d, c],
[c, a, d, b],
];
const first = resolveInstallOrder(sets[0] as PluginManifest[]).map(
(m) => m.name
);
for (const set of sets) {
const got = resolveInstallOrder(set as PluginManifest[]).map(
(m) => m.name
);
expect(got).toEqual(first);
}
});
it("reports cycle path in details", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0", { b: "^1" });
const b = mk("b", "1.0.0", { a: "^1" });
let caught: unknown;
try {
resolveInstallOrder([a, b]);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ManifestError);
const err = caught as ManifestError;
expect(err.code).toBe(MANIFEST_CODES.DEPENDENCY_CYCLE);
const path = err.context?.path as readonly string[];
expect(path[0]).toBe(path[path.length - 1]);
expect(path.length).toBeGreaterThanOrEqual(2);
});
it("reports missing dependency", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0", { ghost: "^1" });
let caught: unknown;
try {
resolveInstallOrder([a]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.MISSING_DEPENDENCY
);
});
it("reports duplicate plugin name", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0");
const a2 = mk("a", "1.0.1");
let caught: unknown;
try {
resolveInstallOrder([a, a2]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.MISSING_DEPENDENCY
);
});
it("reports version mismatch (range unsatisfied)", {
timeout: 5000,
}, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0", { a: "^2" });
let caught: unknown;
try {
resolveInstallOrder([a, b]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.DEPENDENCY_VERSION_MISMATCH
);
});
it("reports version mismatch (unparseable range)", {
timeout: 5000,
}, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0", { a: ">=1.0.0 <2.0.0" });
let caught: unknown;
try {
resolveInstallOrder([a, b]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.DEPENDENCY_VERSION_MISMATCH
);
});
it("empty input yields empty output", { timeout: 5000 }, () => {
expect(resolveInstallOrder([])).toEqual([]);
});
it("manifest without dependsOn field resolves as a root", {
timeout: 5000,
}, () => {
const noDeps: PluginManifest = { ...MINIMAL_FIXTURE, name: "lonely" };
// ensure dependsOn key is absent (MINIMAL_FIXTURE already omits it)
expect("dependsOn" in noDeps).toBe(false);
const ordered = resolveInstallOrder([noDeps]);
expect(ordered.map((m) => m.name)).toEqual(["lonely"]);
});
it("triangle cycle path includes all nodes", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0", { b: "^1" });
const b = mk("b", "1.0.0", { c: "^1" });
const c = mk("c", "1.0.0", { a: "^1" });
let caught: unknown;
try {
resolveInstallOrder([a, b, c]);
} catch (e) {
caught = e;
}
const path = (caught as ManifestError).context
?.path as readonly string[];
expect(path[0]).toBe(path[path.length - 1]);
});
});

View File

@@ -0,0 +1,157 @@
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { satisfies } from "./semver.js";
/**
* Topologically order the supplied manifests so dependencies precede
* dependents. Determinism is guaranteed by lexicographic tie-breaking
* — shuffling the input produces identical output.
*
* Throws on:
* - cycle → `MANIFEST.DEPENDENCY_CYCLE` with `context.path: string[]`
* forming the cycle (head equals tail).
* - missing dep → `MANIFEST.MISSING_DEPENDENCY`.
* - version mismatch (semver) → `MANIFEST.DEPENDENCY_VERSION_MISMATCH`.
*/
export function resolveInstallOrder(
manifests: readonly PluginManifest[]
): readonly PluginManifest[] {
const byName = new Map<string, PluginManifest>();
for (const m of manifests) {
if (byName.has(m.name)) {
throw new ManifestError(
MANIFEST_CODES.MISSING_DEPENDENCY,
"manifest.duplicate_plugin",
{ context: { name: m.name } }
);
}
byName.set(m.name, m);
}
checkDepsExistAndMatch(manifests, byName);
return kahn(manifests, byName);
}
function checkDepsExistAndMatch(
manifests: readonly PluginManifest[],
byName: ReadonlyMap<string, PluginManifest>
): void {
for (const m of manifests) {
const deps = m.dependsOn ?? {};
for (const [depName, range] of Object.entries(deps)) {
const dep = byName.get(depName);
if (!dep) {
throw new ManifestError(
MANIFEST_CODES.MISSING_DEPENDENCY,
"manifest.missing_dependency",
{ context: { plugin: m.name, missing: depName } }
);
}
let ok: boolean;
try {
ok = satisfies(dep.version, range);
} catch {
throw new ManifestError(
MANIFEST_CODES.DEPENDENCY_VERSION_MISMATCH,
"manifest.dependency_version_mismatch",
{
context: {
plugin: m.name,
dependency: depName,
requested: range,
actual: dep.version,
},
}
);
}
if (!ok) {
throw new ManifestError(
MANIFEST_CODES.DEPENDENCY_VERSION_MISMATCH,
"manifest.dependency_version_mismatch",
{
context: {
plugin: m.name,
dependency: depName,
requested: range,
actual: dep.version,
},
}
);
}
}
}
}
function kahn(
manifests: readonly PluginManifest[],
byName: ReadonlyMap<string, PluginManifest>
): readonly PluginManifest[] {
const remaining = new Map<string, number>();
const dependents = new Map<string, string[]>();
for (const m of manifests) {
const depNames = Object.keys(m.dependsOn ?? {});
remaining.set(m.name, depNames.length);
for (const d of depNames) {
const list = dependents.get(d) ?? [];
list.push(m.name);
dependents.set(d, list);
}
}
const ready: string[] = [];
for (const [name, count] of remaining) {
if (count === 0) ready.push(name);
}
ready.sort();
const order: PluginManifest[] = [];
while (ready.length > 0) {
const name = ready.shift() as string;
order.push(byName.get(name) as PluginManifest);
const next = dependents.get(name) ?? [];
const batch: string[] = [];
for (const dep of next) {
const left = (remaining.get(dep) as number) - 1;
remaining.set(dep, left);
if (left === 0) batch.push(dep);
}
batch.sort();
for (const b of batch) ready.push(b);
}
if (order.length !== manifests.length) {
const stuck = [...remaining]
.filter(([, c]) => c > 0)
.map(([n]) => n)
.sort();
throw new ManifestError(
MANIFEST_CODES.DEPENDENCY_CYCLE,
"manifest.dependency_cycle",
{ context: { path: findCycle(stuck, byName) } }
);
}
return order;
}
function findCycle(
stuck: readonly string[],
byName: ReadonlyMap<string, PluginManifest>
): readonly string[] {
// Every member of `stuck` has non-zero in-degree after Kahn — each
// depends on at least one other stuck member, so the walk-of-first-
// stuck-neighbour eventually re-enters the path stack. The re-entry
// slice is the reported cycle.
const stuckSet = new Set(stuck);
const path: string[] = [];
const onPath = new Set<string>();
function walk(node: string): readonly string[] {
if (onPath.has(node)) {
const cut = path.indexOf(node);
return [...path.slice(cut), node];
}
path.push(node);
onPath.add(node);
const m = byName.get(node) as PluginManifest;
const deps = Object.keys(m.dependsOn as Record<string, string>)
.filter((d) => stuckSet.has(d))
.sort();
return walk(deps[0] as string);
}
return walk(stuck[0] as string);
}

View File

@@ -0,0 +1,2 @@
export { resolveInstallOrder } from "./dep-graph.js";
export { satisfies } from "./semver.js";

View File

@@ -0,0 +1,45 @@
# `satisfies`
## Purpose
Small in-house semver-range matcher used by the dep-graph and capability resolvers. Covers a deliberately narrow subset: pinned (`1.2.3`), caret (`^1.2.3`, `^1.2`, `^1`), tilde (`~1.2.3`, `~1.2`, `~1`), and the wildcards `*` / `latest`. Anything outside that subset throws so the caller can surface a stable error code.
## Features
- Strict SemVer 2.0.0 parse of the candidate version
- Caret semantics match npm's: `^0.x.y` stays inside the `0.x` line
- Tilde semantics: `~1.2.3` matches `1.2.>=3`, `~1.2` matches `1.2.*`
- No external dependency on `semver` — fewer bytes shipped to consumers
## Basic Example
```ts
import { satisfies } from "@suaveplan/api-manifest/dep-graph";
satisfies("1.4.2", "^1"); // true
```
## Advanced Example
```ts
import { satisfies } from "@suaveplan/api-manifest/dep-graph";
function matchAny(version: string, ranges: readonly string[]): boolean {
return ranges.some((r) => {
try { return satisfies(version, r); } catch { return false; }
});
}
```
## API Reference
- `satisfies(version: string, range: string): boolean`
- Throws `Error` (mapped by the dep-graph to `MANIFEST.DEPENDENCY_VERSION_MISMATCH`) on unsupported range syntax.
## Implementation Notes
The matcher uses three regexes (full SemVer, major-only, major.minor) and one comparator. Pre-release ordering follows SemVer 2.0.0 — pre-release strings sort lexically and any pre-release is less than the same `M.M.P` without a pre-release. Build metadata is parsed-but-ignored for comparison, matching npm. The deliberate narrowness keeps the comparator total (no `>=1.2.3 || <2.0.0`-style ambiguity); when v2 ships, range unions can be added without breaking the existing surface.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { satisfies } from "./semver.js";
describe("satisfies", () => {
it("wildcard matches anything", { timeout: 5000 }, () => {
expect(satisfies("0.0.1", "*")).toBe(true);
expect(satisfies("99.99.99", "latest")).toBe(true);
});
it("pinned matches exact", { timeout: 5000 }, () => {
expect(satisfies("1.2.3", "1.2.3")).toBe(true);
expect(satisfies("1.2.4", "1.2.3")).toBe(false);
});
it("caret major-only matches major", { timeout: 5000 }, () => {
expect(satisfies("1.4.2", "^1")).toBe(true);
expect(satisfies("2.0.0", "^1")).toBe(false);
});
it("caret major.minor pins minor when major != 0", {
timeout: 5000,
}, () => {
expect(satisfies("1.4.0", "^1.4")).toBe(true);
expect(satisfies("1.5.0", "^1.4")).toBe(true);
});
it("caret major.minor on 0.x pins minor", { timeout: 5000 }, () => {
expect(satisfies("0.4.0", "^0.4")).toBe(true);
expect(satisfies("0.5.0", "^0.4")).toBe(false);
});
it("caret full semver allows patch+ updates", { timeout: 5000 }, () => {
expect(satisfies("1.4.5", "^1.4.2")).toBe(true);
expect(satisfies("1.4.1", "^1.4.2")).toBe(false);
});
it("caret 0.x.y only patch-flex inside same minor", {
timeout: 5000,
}, () => {
expect(satisfies("0.4.5", "^0.4.2")).toBe(true);
expect(satisfies("0.5.0", "^0.4.2")).toBe(false);
});
it("caret 0.0.x pins patch", { timeout: 5000 }, () => {
expect(satisfies("0.0.2", "^0.0.2")).toBe(true);
expect(satisfies("0.0.3", "^0.0.2")).toBe(false);
});
it("caret with unparseable rest is false", { timeout: 5000 }, () => {
expect(satisfies("1.0.0", "^abc")).toBe(false);
});
it("tilde major-only matches major", { timeout: 5000 }, () => {
expect(satisfies("1.5.2", "~1")).toBe(true);
expect(satisfies("2.0.0", "~1")).toBe(false);
});
it("tilde major.minor pins minor", { timeout: 5000 }, () => {
expect(satisfies("1.2.5", "~1.2")).toBe(true);
expect(satisfies("1.3.0", "~1.2")).toBe(false);
});
it("tilde full semver pins patch-floor", { timeout: 5000 }, () => {
expect(satisfies("1.2.5", "~1.2.3")).toBe(true);
expect(satisfies("1.2.2", "~1.2.3")).toBe(false);
expect(satisfies("1.3.0", "~1.2.3")).toBe(false);
});
it("tilde with unparseable rest is false", { timeout: 5000 }, () => {
expect(satisfies("1.0.0", "~abc")).toBe(false);
});
it("unparseable candidate is false", { timeout: 5000 }, () => {
expect(satisfies("not-a-version", "^1")).toBe(false);
});
it("completely unsupported range throws", { timeout: 5000 }, () => {
expect(() => satisfies("1.0.0", ">=1.0.0 <2.0.0")).toThrow();
});
it("pre-release ordering: pre < release at same triple", {
timeout: 5000,
}, () => {
expect(satisfies("1.0.0", "1.0.0-alpha")).toBe(false);
expect(satisfies("1.0.0-alpha", "1.0.0-alpha")).toBe(true);
expect(satisfies("1.0.0-alpha", "^1.0.0")).toBe(false);
});
it("caret crosses major boundary check", { timeout: 5000 }, () => {
expect(satisfies("2.0.0", "^1.4.0")).toBe(false);
});
it("caret 0.x.y when minor differs is false", { timeout: 5000 }, () => {
expect(satisfies("0.5.0", "^0.4.0")).toBe(false);
});
it("caret 0.0.x when patch differs is false", { timeout: 5000 }, () => {
expect(satisfies("0.0.3", "^0.0.2")).toBe(false);
});
it("pre-release sort vs no-pre", { timeout: 5000 }, () => {
// ensure compareSemver handles both pre-empty branches
expect(satisfies("1.0.0-beta", "1.0.0-alpha")).toBe(false);
expect(satisfies("1.0.0-alpha", "1.0.0-beta")).toBe(false);
});
it("caret with pre-release: both pre's non-empty, both directions", {
timeout: 5000,
}, () => {
// a.pre < b.pre branch: candidate pre=alpha, pinned pre=beta -> false
expect(satisfies("1.0.0-alpha", "^1.0.0-beta")).toBe(false);
// a.pre > b.pre branch: candidate pre=gamma, pinned pre=beta -> true
expect(satisfies("1.0.0-gamma", "^1.0.0-beta")).toBe(true);
});
it("caret-full-form with different minor", { timeout: 5000 }, () => {
// hits compareMinorPatchPre minor-diff branch (a.minor > b.minor)
expect(satisfies("1.5.0", "^1.4.2")).toBe(true);
// hits compareMinorPatchPre minor-diff branch (a.minor < b.minor)
expect(satisfies("1.3.0", "^1.4.2")).toBe(false);
});
it("caret-full-form pre-equal between candidate and pinned", {
timeout: 5000,
}, () => {
// a.pre === b.pre, both non-empty -> return 0 -> satisfies
expect(satisfies("1.0.0-alpha", "^1.0.0-alpha")).toBe(true);
});
it("caret-full-form pinned has pre, candidate is release at same triple", {
timeout: 5000,
}, () => {
// minor + patch identical, a.pre === "" branch -> return 1 -> satisfies
expect(satisfies("1.0.0", "^1.0.0-beta")).toBe(true);
});
});

View File

@@ -0,0 +1,106 @@
/**
* Small in-house semver matcher. Covers the subset the manifest dep
* resolver needs: pinned (`1.2.3`), caret (`^1.2.3`), tilde (`~1.2.3`),
* wildcard major (`^1`, `~1`), and `*` / `latest` (always match).
*
* For anything beyond this subset (`>=1.2.3 <2.0.0`, `||`, etc.) we
* surface `MANIFEST.DEPENDENCY_VERSION_MISMATCH` rather than silently
* accept — the foundation is intentionally narrow at v1.
*/
interface SemVer {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly pre: string;
}
const SEMVER_RE =
/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
const MAJOR_RE = /^(\d+)$/;
const MAJOR_MINOR_RE = /^(\d+)\.(\d+)$/;
function parse(v: string): SemVer | null {
const m = SEMVER_RE.exec(v);
if (!m) return null;
return {
major: Number(m[1]),
minor: Number(m[2]),
patch: Number(m[3]),
pre: m[4] ?? "",
};
}
/**
* `true` iff `version` satisfies `range`. Throws when `range` cannot
* be classified — caller maps the throw to
* `MANIFEST.DEPENDENCY_VERSION_MISMATCH` with the unparsed range.
*/
export function satisfies(version: string, range: string): boolean {
if (range === "*" || range === "latest") return true;
const v = parse(version);
if (!v) return false;
if (range.startsWith("^")) return matchCaret(v, range.slice(1));
if (range.startsWith("~")) return matchTilde(v, range.slice(1));
const pinned = parse(range);
if (pinned !== null) return equalSemver(v, pinned);
throw new Error(`unsupported semver range: ${range}`);
}
function matchCaret(v: SemVer, rest: string): boolean {
const majorOnly = MAJOR_RE.exec(rest);
if (majorOnly) {
const major = Number(majorOnly[1]);
return v.major === major;
}
const mm = MAJOR_MINOR_RE.exec(rest);
if (mm) {
const major = Number(mm[1]);
const minor = Number(mm[2]);
return v.major === major && (major !== 0 || v.minor === minor);
}
const pinned = parse(rest);
if (!pinned) return false;
if (v.major !== pinned.major) return false;
if (pinned.major === 0) {
if (v.minor !== pinned.minor) return false;
if (pinned.minor === 0) return v.patch === pinned.patch;
return v.patch >= pinned.patch;
}
// major matched and is non-zero — minor/patch ordering decides.
return compareMinorPatchPre(v, pinned) >= 0;
}
function matchTilde(v: SemVer, rest: string): boolean {
const majorOnly = MAJOR_RE.exec(rest);
if (majorOnly) return v.major === Number(majorOnly[1]);
const mm = MAJOR_MINOR_RE.exec(rest);
if (mm) {
return v.major === Number(mm[1]) && v.minor === Number(mm[2]);
}
const pinned = parse(rest);
if (!pinned) return false;
return (
v.major === pinned.major &&
v.minor === pinned.minor &&
v.patch >= pinned.patch
);
}
function equalSemver(a: SemVer, b: SemVer): boolean {
return (
a.major === b.major &&
a.minor === b.minor &&
a.patch === b.patch &&
a.pre === b.pre
);
}
function compareMinorPatchPre(a: SemVer, b: SemVer): number {
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
if (a.pre === b.pre) return 0;
if (a.pre === "") return 1;
if (b.pre === "") return -1;
return a.pre < b.pre ? -1 : 1;
}

View File

@@ -0,0 +1,61 @@
# `MANIFEST_CODES` and `ManifestError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen `MANIFEST.*` error-code registry plus the concrete `SuaveplanError` subclass thrown by every public path in this package. The registry is built with `defineCodes` so every entry is a literal-typed `MANIFEST.<REASON>` string, surviving tree-shaking and minification. `ManifestError` narrows the base class's `TCode` parameter to that union so `instanceof ManifestError` plus `error.code` is a discriminated union at the consumer site.
## Features
- Eleven stable codes covering parse, dep-graph, capability, and validator failures
- Literal-typed `ManifestCode` union derived from the registry
- `ManifestError` subclass preserving the base cause-chain, context, OTel adapter, and i18n message-key resolution
- Every code has at least one production-path reference enforced by a spec-conformance test
## Basic Example
```ts
import { MANIFEST_CODES, ManifestError } from "@suaveplan/api-manifest/error-codes";
throw new ManifestError(
MANIFEST_CODES.PARSE_FAILED,
"manifest.parse_failed",
{ context: { issues: [] } },
);
```
## Advanced Example
```ts
import {
MANIFEST_CODES,
type ManifestCode,
ManifestError,
} from "@suaveplan/api-manifest/error-codes";
function handle(err: unknown): ManifestCode | null {
if (err instanceof ManifestError) return err.code;
return null;
}
const code: ManifestCode = MANIFEST_CODES.DEPENDENCY_CYCLE;
```
## API Reference
- `MANIFEST_CODES` — `Readonly<Record<Reason, \`MANIFEST.${Reason}\`>>`.
- `ManifestCode` — string-literal union of every registered code.
- `ManifestError` — `class ManifestError extends SuaveplanError<ManifestCode>`.
## Implementation Notes
`defineCodes("MANIFEST", [...])` returns a frozen object so consumers cannot accidentally mutate it at runtime; combined with `as const` on the reason array the type system pins every key + value. The eleven codes are: `PARSE_FAILED`, `DEPENDENCY_CYCLE`, `MISSING_DEPENDENCY`, `DEPENDENCY_VERSION_MISMATCH`, `CAPABILITY_REQUIRED_MISSING`, `CAPABILITY_VERSION_MISMATCH`, `OPTIONAL_CAPABILITY_MISSING`, `INVALID_SAGA_OWNERSHIP`, `SANDBOX_REQUIRES_BUDGETS`, `INVALID_ERROR_STATUS_MAP`, `OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES`. Adding a new code is an spec change because every reachable code is pinned by the spec-conformance test.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,37 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES } from "./error-codes.js";
describe("every MANIFEST_CODES entry is thrown by a production path", () => {
it("every reason key has at least one production-path reference outside error-codes.ts", {
timeout: 5000,
}, () => {
const srcDir = join(__dirname, "..");
const files: string[] = [];
walk(srcDir, (f) => {
if (!f.endsWith(".ts")) return;
if (f.endsWith(".test.ts")) return;
if (f.endsWith("error-codes.ts")) return;
if (f.endsWith("spec-conformance.test.ts")) return;
files.push(f);
});
const corpus = files.map((f) => readFileSync(f, "utf8")).join("\n");
const missing: string[] = [];
for (const key of Object.keys(MANIFEST_CODES)) {
if (!corpus.includes(`MANIFEST_CODES.${key}`)) {
missing.push(key);
}
}
expect(missing).toEqual([]);
});
});
function walk(dir: string, fn: (file: string) => void): void {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, fn);
else fn(p);
}
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MANIFEST_CODES,
type ManifestCode,
ManifestError,
} from "./error-codes.js";
describe("MANIFEST_CODES", () => {
it("exposes every expected code", { timeout: 5000 }, () => {
const expected = [
"PARSE_FAILED",
"DEPENDENCY_CYCLE",
"MISSING_DEPENDENCY",
"DEPENDENCY_VERSION_MISMATCH",
"CAPABILITY_REQUIRED_MISSING",
"CAPABILITY_VERSION_MISMATCH",
"OPTIONAL_CAPABILITY_MISSING",
"INVALID_SAGA_OWNERSHIP",
"SANDBOX_REQUIRES_BUDGETS",
"INVALID_ERROR_STATUS_MAP",
"OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES",
] as const;
for (const k of expected) {
expect(MANIFEST_CODES[k as keyof typeof MANIFEST_CODES]).toBe(
`MANIFEST.${k}`
);
}
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(MANIFEST_CODES)).toBe(true);
});
});
describe("ManifestError", () => {
it("constructs with code, message-key, and options", {
timeout: 5000,
}, () => {
const err = new ManifestError(
MANIFEST_CODES.PARSE_FAILED,
"manifest.parse_failed",
{ context: { foo: 1 } }
);
expect(err.code).toBe(MANIFEST_CODES.PARSE_FAILED);
expect(err.context?.["foo"]).toBe(1);
expect(err.name).toBe("ManifestError");
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new ManifestError(
MANIFEST_CODES.PARSE_FAILED,
"manifest.parse_failed"
);
expect(err).toBeInstanceOf(ManifestError);
});
it("`code` is narrowed to the ManifestCode union", {
timeout: 5000,
}, () => {
const code: ManifestCode = MANIFEST_CODES.DEPENDENCY_CYCLE;
expect(code).toBe("MANIFEST.DEPENDENCY_CYCLE");
});
});

View File

@@ -0,0 +1,46 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of every error code surfaced by `@suaveplan/api-manifest`.
*
* Every code MUST be thrown (or attached to a warning) by at least one
* production code path; a spec-conformance test enforces this invariant.
*/
export const MANIFEST_CODES = defineCodes("MANIFEST", [
"PARSE_FAILED",
"DEPENDENCY_CYCLE",
"MISSING_DEPENDENCY",
"DEPENDENCY_VERSION_MISMATCH",
"CAPABILITY_REQUIRED_MISSING",
"CAPABILITY_VERSION_MISMATCH",
"OPTIONAL_CAPABILITY_MISSING",
"INVALID_SAGA_OWNERSHIP",
"SANDBOX_REQUIRES_BUDGETS",
"INVALID_ERROR_STATUS_MAP",
"OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES",
] as const);
/** Literal union of every error code emitted by this package. */
export type ManifestCode = (typeof MANIFEST_CODES)[keyof typeof MANIFEST_CODES];
/**
* Typed error class for `@suaveplan/api-manifest` failures.
*
* Extends `SuaveplanError` so callers can `instanceof ManifestError` to
* narrow the code union while retaining the base class's cause chain,
* context, redaction, and OTel span-event adapter.
*/
export class ManifestError extends SuaveplanError<ManifestCode> {
public constructor(
code: ManifestCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "ManifestError";
}
}

View File

@@ -0,0 +1,5 @@
export {
MANIFEST_CODES,
type ManifestCode,
ManifestError,
} from "./error-codes.js";

View File

@@ -0,0 +1,44 @@
# `canonicalJson`
## Purpose
Canonical JSON encoder used by `hashManifest`. Object keys are sorted lexicographically at every depth; array order is preserved; whitespace is omitted; `undefined` properties are dropped. The encoder rejects non-finite numbers, bigints, and cyclic structures so two manifests with the same logical content always produce the same byte stream.
## Features
- Deep key-sort at every object depth
- Cycle detection via `WeakSet`
- Reject non-finite numbers and bigints
- `undefined`-as-value coerces to `null` to match JSON.stringify
## Basic Example
```ts
import { canonicalJson } from "@suaveplan/api-manifest/hash";
canonicalJson({ b: 1, a: 2 }); // '{"a":2,"b":1}'
```
## Advanced Example
```ts
import { canonicalJson } from "@suaveplan/api-manifest/hash";
import { createHash } from "node:crypto";
function etag(value: unknown): string {
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
}
```
## API Reference
- `canonicalJson(value: unknown): string` — throws on cycles, bigints, non-finite numbers, and `typeof` values JSON cannot represent (functions, symbols).
## Implementation Notes
Array order is preserved by design because saga step order is semantically meaningful — reordering steps would change the saga's behaviour, so two manifests with reordered steps must hash differently. `undefined` as a property value collapses to nothing (matching `JSON.stringify`); `undefined` as an array element coerces to `"null"` to keep the array length stable. The encoder is recursive but bounded by the tree depth; the WeakSet catches cycles before stack-overflow becomes a failure mode.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { canonicalJson } from "./canonical-json.js";
describe("canonicalJson", () => {
it("primitives encode like JSON.stringify", { timeout: 5000 }, () => {
expect(canonicalJson("a")).toBe('"a"');
expect(canonicalJson(1)).toBe("1");
expect(canonicalJson(true)).toBe("true");
expect(canonicalJson(false)).toBe("false");
expect(canonicalJson(null)).toBe("null");
expect(canonicalJson(undefined)).toBe("null");
});
it("sorts object keys lexicographically", { timeout: 5000 }, () => {
expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
});
it("preserves array order", { timeout: 5000 }, () => {
expect(canonicalJson([3, 1, 2])).toBe("[3,1,2]");
});
it("sorts keys at every depth", { timeout: 5000 }, () => {
expect(canonicalJson({ b: { d: 1, c: 2 }, a: 0 })).toBe(
'{"a":0,"b":{"c":2,"d":1}}'
);
});
it("drops undefined object properties", { timeout: 5000 }, () => {
expect(canonicalJson({ a: undefined, b: 1 })).toBe('{"b":1}');
});
it("rejects non-finite numbers", { timeout: 5000 }, () => {
expect(() => canonicalJson(Number.NaN)).toThrow();
expect(() => canonicalJson(Number.POSITIVE_INFINITY)).toThrow();
});
it("rejects bigints", { timeout: 5000 }, () => {
expect(() => canonicalJson(BigInt(1))).toThrow();
});
it("rejects functions / symbols / etc.", { timeout: 5000 }, () => {
expect(() => canonicalJson(() => 1)).toThrow();
expect(() => canonicalJson(Symbol("x") as unknown)).toThrow();
});
it("detects object cycles", { timeout: 5000 }, () => {
const a: Record<string, unknown> = { x: 1 };
a.self = a;
expect(() => canonicalJson(a)).toThrow();
});
it("detects array cycles", { timeout: 5000 }, () => {
const a: unknown[] = [];
a.push(a);
expect(() => canonicalJson(a)).toThrow();
});
});

View File

@@ -0,0 +1,49 @@
/**
* Canonical JSON encoder. Object keys are sorted lexicographically at
* every depth; arrays preserve their insertion order (positional
* semantics are preserved by design — saga step order matters); no
* whitespace, no trailing newline.
*
* Throws on cycles and on values JSON cannot represent (functions,
* symbols, bigints). `undefined` properties are dropped (matching
* `JSON.stringify` semantics).
*/
export function canonicalJson(value: unknown): string {
return encode(value, new WeakSet<object>());
}
function encode(value: unknown, seen: WeakSet<object>): string {
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new Error("canonical-json: non-finite number");
}
return JSON.stringify(value);
}
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "bigint") {
throw new Error("canonical-json: bigint not representable");
}
if (typeof value === "undefined") return "null";
if (typeof value !== "object") {
throw new Error(`canonical-json: cannot encode typeof ${typeof value}`);
}
if (seen.has(value)) throw new Error("canonical-json: cycle detected");
seen.add(value);
if (Array.isArray(value)) {
const parts = value.map((v) => encode(v, seen));
seen.delete(value);
return `[${parts.join(",")}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj)
.filter((k) => obj[k] !== undefined)
.sort();
const parts: string[] = [];
for (const k of keys) {
parts.push(`${JSON.stringify(k)}:${encode(obj[k], seen)}`);
}
seen.delete(value);
return `{${parts.join(",")}}`;
}

View File

@@ -0,0 +1,49 @@
# `hashManifest`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compute `sha256:<hex>` over the canonical-JSON encoding of a manifest. Used by `RollbackJournal` entries and the plugin ledger to detect drift between install and reload — a hash mismatch is a hard failure because the manifest is the host's source of truth for the plugin's declarative facts.
## Features
- SHA-256 via Node's `node:crypto` `createHash`
- Canonical JSON encoding via the sibling `canonicalJson` helper
- Hex-encoded digest with a `sha256:` prefix
- Key-order independence — only value differences alter the hash
## Basic Example
```ts
import { hashManifest } from "@suaveplan/api-manifest/hash";
const h = hashManifest(manifest);
```
## Advanced Example
```ts
import { hashManifest } from "@suaveplan/api-manifest/hash";
import { parseManifest } from "@suaveplan/api-manifest/parse";
function detectDrift(prevHash: string, raw: unknown): boolean {
const next = hashManifest(parseManifest(raw));
return next !== prevHash;
}
```
## API Reference
- `hashManifest(manifest: PluginManifest): string` returning `sha256:<64-hex>`.
## Implementation Notes
The `sha256:` prefix mirrors the multibase-style convention used elsewhere in the crypto stack so callers can identify the algorithm without sniffing the digest length. Using `node:crypto` keeps the dependency surface to zero — the rollback journal lives in the host (server tier) so this is acceptable. If a future browser-tier consumer needs the same hash, a thin SubtleCrypto adapter can be added behind a conditional export.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,41 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import { canonicalJson } from "./canonical-json.js";
import { hashManifest } from "./hash.js";
describe("hashManifest", () => {
it("key-order independent", { timeout: 5000 }, () => {
const a = { ...MINIMAL_FIXTURE };
const b = {
isolation: MINIMAL_FIXTURE.isolation,
trustLevel: MINIMAL_FIXTURE.trustLevel,
apiHost: MINIMAL_FIXTURE.apiHost,
version: MINIMAL_FIXTURE.version,
name: MINIMAL_FIXTURE.name,
};
expect(hashManifest(a)).toBe(hashManifest(b));
});
it("returns sha256:<64-hex>", { timeout: 5000 }, () => {
const h = hashManifest(MINIMAL_FIXTURE);
expect(h.startsWith("sha256:")).toBe(true);
expect(h.slice(7).length).toBe(64);
expect(/^[0-9a-f]+$/.test(h.slice(7))).toBe(true);
});
it("known-answer vector for minimal manifest", { timeout: 5000 }, () => {
// KAT computed from canonicalJson + sha256 of the minimal fixture.
const expectedCanonical = canonicalJson(MINIMAL_FIXTURE);
const expectedHex = createHash("sha256")
.update(expectedCanonical, "utf8")
.digest("hex");
expect(hashManifest(MINIMAL_FIXTURE)).toBe(`sha256:${expectedHex}`);
});
it("different value → different hash", { timeout: 5000 }, () => {
const a = MINIMAL_FIXTURE;
const b = { ...MINIMAL_FIXTURE, version: "0.1.1" };
expect(hashManifest(a)).not.toBe(hashManifest(b));
});
});

View File

@@ -0,0 +1,17 @@
import { createHash } from "node:crypto";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { canonicalJson } from "./canonical-json.js";
/**
* Compute `"sha256:<hex>"` over the canonical-JSON encoding of `manifest`.
*
* Used by `RollbackJournal` entries and the plugin ledger to detect
* drift across reload. Two manifests differing only in key insertion
* order MUST hash identically; any value-level difference (including
* positional differences inside an array) MUST hash differently.
*/
export function hashManifest(manifest: PluginManifest): string {
const canonical = canonicalJson(manifest);
const hex = createHash("sha256").update(canonical, "utf8").digest("hex");
return `sha256:${hex}`;
}

View File

@@ -0,0 +1,2 @@
export { canonicalJson } from "./canonical-json.js";
export { hashManifest } from "./hash.js";

View File

@@ -0,0 +1,39 @@
// Error codes
// Capability resolver
export {
type CapabilityDescriptor,
type CapabilityResolution,
type CapabilityWarning,
type ResolveCapabilitiesInput,
resolveCapabilities,
} from "./capability-resolver/capability-resolver.js";
// Dep graph
export { resolveInstallOrder } from "./dep-graph/dep-graph.js";
export { satisfies } from "./dep-graph/semver.js";
export {
MANIFEST_CODES,
type ManifestCode,
ManifestError,
} from "./error-codes/error-codes.js";
// Hash
export { canonicalJson } from "./hash/canonical-json.js";
export { hashManifest } from "./hash/hash.js";
// Parse
export {
type ParseResult,
parseManifest,
parseManifestSafe,
} from "./parse/parse.js";
// Schema
export {
type AssignableToApiTypes,
type PluginManifest,
pluginManifestSchema,
} from "./schema/plugin-manifest.js";
export { validateErrorStatusMapShape } from "./validators/validate-error-status-map-shape.js";
export { validateOpenApiRequiresGraphqlOrRoutes } from "./validators/validate-openapi-requires-graphql-or-routes.js";
export { validatePermissionsWhenSandboxed } from "./validators/validate-permissions-when-sandboxed.js";
export { validateSagaOwnership } from "./validators/validate-saga-ownership.js";
// Validators
export type { ValidationResult } from "./validators/validation-result.types.js";

View File

@@ -0,0 +1,5 @@
export {
type ParseResult,
parseManifest,
parseManifestSafe,
} from "./parse.js";

View File

@@ -0,0 +1,51 @@
# `parseManifest` and `parseManifestSafe`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Two top-level entry points for converting an unknown JSON value into a typed `PluginManifest`. `parseManifest` is the throw-form (suitable for the install gate where bad input is a fatal failure); `parseManifestSafe` returns a discriminated `ParseResult` (suitable for admin tooling that wants to render every Zod issue without try/catch). Both share the same underlying `pluginManifestSchema` so their accept/reject decisions are bit-for-bit identical.
## Features
- Single-pass safe parsing with all Zod issues surfaced
- Stable `MANIFEST.PARSE_FAILED` code on every failure
- Underlying Zod issues array preserved on `error.cause` and `error.context.issues`
- Idempotent — running parse on a previously-parsed manifest is a no-op
## Basic Example
```ts
import { parseManifest } from "@suaveplan/api-manifest/parse";
const manifest = parseManifest({ name: "x", version: "0.1.0", apiHost: "^1", isolation: "isolated", trustLevel: "first-party" });
```
## Advanced Example
```ts
import { parseManifestSafe } from "@suaveplan/api-manifest/parse";
const result = parseManifestSafe(unknownInput);
if (!result.ok) {
for (const issue of (result.error.cause as { issues: { path: (string|number)[]; message: string }[] }).issues) {
console.error(issue.path.join("."), issue.message);
}
}
```
## API Reference
- `parseManifest(input: unknown): PluginManifest` — throws `ManifestError` (`MANIFEST.PARSE_FAILED`).
- `parseManifestSafe(input: unknown): ParseResult` — returns `{ ok: true; data } | { ok: false; error }`.
- `ParseResult` — discriminated result type.
## Implementation Notes
Both functions go through `pluginManifestSchema.safeParse` so the strict-mode unknown-key rejection is enforced uniformly. The thrown/returned `ManifestError` carries the raw Zod issues array twice — once on `cause` (because `cause` is the canonical place to chain errors in modern Error) and once on `context.issues` (so the OTel span-event adapter picks it up automatically without needing a special `cause` walker).
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import { parseManifest, parseManifestSafe } from "./parse.js";
describe("parseManifest", () => {
it("throw form on invalid input", { timeout: 5000 }, () => {
let caught: unknown;
try {
parseManifest({});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ManifestError);
const err = caught as ManifestError;
expect(err.code).toBe(MANIFEST_CODES.PARSE_FAILED);
const cause = err.cause as { issues: unknown[] };
expect(Array.isArray(cause.issues)).toBe(true);
expect(cause.issues.length).toBeGreaterThan(0);
});
it("throw form returns typed manifest on success", {
timeout: 5000,
}, () => {
const m = parseManifest(MINIMAL_FIXTURE);
expect(m.name).toBe(MINIMAL_FIXTURE.name);
});
});
describe("parseManifestSafe", () => {
it("safe form preserves error envelope", { timeout: 5000 }, () => {
const r = parseManifestSafe({});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.PARSE_FAILED);
expect(r.error).toBeInstanceOf(ManifestError);
}
});
it("safe form returns ok+data on success", { timeout: 5000 }, () => {
const r = parseManifestSafe(MINIMAL_FIXTURE);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.data.name).toBe(MINIMAL_FIXTURE.name);
}
});
});

View File

@@ -0,0 +1,50 @@
import type z from "zod";
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import {
type PluginManifest,
pluginManifestSchema,
} from "../schema/plugin-manifest.js";
/**
* Discriminated result for the safe parse path. `data` is present on
* success, `error` on failure — never both.
*/
export type ParseResult =
| { readonly ok: true; readonly data: PluginManifest }
| { readonly ok: false; readonly error: ManifestError };
/**
* Strict-parse a plugin manifest. Throws `ManifestError` with code
* `MANIFEST.PARSE_FAILED` on any Zod issue; the underlying Zod issues
* array is preserved on `error.cause` so callers can render granular
* field-level diagnostics.
*
* @throws {ManifestError} `MANIFEST.PARSE_FAILED`
*/
export function parseManifest(input: unknown): PluginManifest {
const result = pluginManifestSchema.safeParse(input);
if (result.success) return result.data;
throw makeError(result.error);
}
/**
* Non-throwing variant. Returns a discriminated `ParseResult`. The
* error carries the same code + cause as the throw path so consumers
* can render diagnostics without try/catch.
*/
export function parseManifestSafe(input: unknown): ParseResult {
const result = pluginManifestSchema.safeParse(input);
if (result.success) return { ok: true, data: result.data };
return { ok: false, error: makeError(result.error) };
}
function makeError(zodError: z.ZodError): ManifestError {
return new ManifestError(
MANIFEST_CODES.PARSE_FAILED,
"manifest.parse_failed",
{
cause: zodError,
context: { issues: zodError.issues },
}
);
}

View File

@@ -0,0 +1,51 @@
# `compatSchema`
## Purpose
Validates the compatibility block of a plugin manifest: the `apiHost` semver range and the two capability arrays (`requiredCapabilities`, `optionalCapabilities`). These three fields are the host's gate for deciding whether a plugin can boot against a given runtime version with a given capability matrix.
## Features
- `apiHost` is a non-empty string (semver-range syntax checked downstream)
- `requiredCapabilities` is an optional readonly string array
- `optionalCapabilities` is an optional readonly string array
- Inferred shape mirrors `api-types`' compat slice exactly
## Basic Example
```ts
import { compatSchema } from "@suaveplan/api-manifest/schema";
compatSchema.parse({
apiHost: "^1",
requiredCapabilities: ["db.transactions"],
});
```
## Advanced Example
```ts
import { compatSchema } from "@suaveplan/api-manifest/schema";
import { resolveCapabilities } from "@suaveplan/api-manifest/capability-resolver";
const compat = compatSchema.parse(rawCompat);
resolveCapabilities({
manifest: { ...manifestFields, ...compat },
registered,
});
```
## API Reference
- `compatSchema: z.ZodObject<{ apiHost, requiredCapabilities?, optionalCapabilities? }>`
- `CompatFields = z.infer<typeof compatSchema>`
## Implementation Notes
The `apiHost` field is intentionally a free-form string at the schema layer because the dep-graph's `satisfies()` accepts the same range syntax — moving the range validation into the schema would couple this module to the semver matcher. Capability names are checked for non-emptiness only; the host validates them against the live capability descriptor set via `resolveCapabilities`. Both arrays are typed as `readonly` to prevent accidental mutation by consumer code.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,14 @@
import z from "zod";
/**
* Compatibility / capability declarations. `apiHost` is a semver range
* the host's runtime version must satisfy; the two capability arrays
* feed the capability-resolver subpath.
*/
export const compatSchema = z.object({
apiHost: z.string().min(1, "apiHost semver range required"),
requiredCapabilities: z.array(z.string().min(1)).readonly().optional(),
optionalCapabilities: z.array(z.string().min(1)).readonly().optional(),
});
export type CompatFields = z.infer<typeof compatSchema>;

View File

@@ -0,0 +1,44 @@
# `configSchema`
## Purpose
Plugin-level config block. `schemaPath` points at a JSON Schema the host validates user-supplied config against at install; `defaults` is a free-form record the host deep-merges on top of user-supplied config before validation runs.
## Features
- Optional `schemaPath` to a JSON Schema document
- Optional `defaults` as `Record<string, unknown>`
- Empty config block is valid (the plugin may need no config)
- Layered on top of the host's `@suaveplan/config` for final resolution
## Basic Example
```ts
import { configSchema } from "@suaveplan/api-manifest/schema";
configSchema.parse({ defaults: { trialDays: 14 } });
```
## Advanced Example
```ts
import { configSchema } from "@suaveplan/api-manifest/schema";
const cfg = configSchema.parse({
schemaPath: "config.schema.json",
defaults: { trialDays: 14, currency: "USD" },
});
```
## API Reference
- `configSchema: z.ZodObject<{ schemaPath?, defaults? }>`
- `ConfigBlock = z.infer<typeof configSchema>`
## Implementation Notes
The schema deliberately doesn't validate the contents of `defaults` because they're plugin-specific by definition; the JSON Schema at `schemaPath` is the canonical validator for the user-supplied config. Defaults are merged in by the host using a deep-merge strategy that favours user-supplied values; arrays are replaced rather than concatenated to keep the merge total.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,13 @@
import z from "zod";
/**
* Plugin-level config block. `schemaPath` points at a JSON Schema for
* the host to validate user-supplied config at install; `defaults` is
* deep-merged on top before validation runs.
*/
export const configSchema = z.object({
schemaPath: z.string().min(1).optional(),
defaults: z.record(z.string(), z.unknown()).optional(),
});
export type ConfigBlock = z.infer<typeof configSchema>;

View File

@@ -0,0 +1,49 @@
# `databaseSchema`
## Purpose
Database-wiring block. Pins whether the plugin needs a database connection, which engines it supports, its current schema version, and how tenant isolation should be expressed. The host's migration runner reads `schemaVersion` + `migrationsDir` at install to apply forward-only migrations before plugin code runs.
## Features
- `required: false` lets a plugin opt out of database wiring entirely
- `preferredEngine` + `compatibleEngines` describe the engine compatibility matrix
- `schemaVersion` is a non-negative integer (0 = pre-migration)
- Three tenant-isolation modes: `row-level`, `schema`, `database`
## Basic Example
```ts
import { databaseSchema } from "@suaveplan/api-manifest/schema";
databaseSchema.parse({ required: true, schemaVersion: 0 });
```
## Advanced Example
```ts
import { databaseSchema } from "@suaveplan/api-manifest/schema";
const db = databaseSchema.parse({
required: true,
preferredEngine: "postgres",
compatibleEngines: ["postgres", "sqlite", "surrealdb"],
schemaVersion: 7,
migrationsDir: "migrations",
tenantIsolation: "row-level",
tablePrefix: "billing_",
});
```
## API Reference
- `databaseSchema: z.ZodObject<{ required, preferredEngine?, compatibleEngines?, schemaVersion, migrationsDir?, tenantIsolation?, tablePrefix? }>`
- `DatabaseBlock = z.infer<typeof databaseSchema>`
## Implementation Notes
Engine names are free-form strings rather than an enum so a plugin can declare compatibility with an engine the host doesn't know about yet — the capability resolver decides at boot whether any declared engine is available. `schemaVersion` is a single integer instead of a range; the host's migration runner walks every step from the journal's last-applied version up to this number. `tablePrefix` is a hint for engines that support it (Postgres/SQLite); SurrealDB uses table namespaces instead.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,17 @@
import z from "zod";
/**
* Database wiring block. `tenantIsolation` is the row vs schema vs
* database trade-off carried by the host's tenant resolver.
*/
export const databaseSchema = z.object({
required: z.boolean(),
preferredEngine: z.string().min(1).optional(),
compatibleEngines: z.array(z.string().min(1)).readonly().optional(),
schemaVersion: z.number().int().min(0),
migrationsDir: z.string().min(1).optional(),
tenantIsolation: z.enum(["row-level", "schema", "database"]).optional(),
tablePrefix: z.string().min(1).optional(),
});
export type DatabaseBlock = z.infer<typeof databaseSchema>;

View File

@@ -0,0 +1,47 @@
# `dependsOnSchema`
## Purpose
Map of plugin-name → semver range used by the dep-graph resolver. Names follow the same npm-package convention as `identity.name`; ranges follow the same syntax the in-house `satisfies()` accepts (pinned, caret, tilde, wildcard, `*`/`latest`).
## Features
- Free-form record keys (plugin names) — keyspace is checked downstream by the dep-graph against the actual install set
- Range values are non-empty strings
- Readonly map output discourages mutation
- Empty map (`{}`) parses successfully — a plugin with no dependencies is a leaf
## Basic Example
```ts
import { dependsOnSchema } from "@suaveplan/api-manifest/schema";
dependsOnSchema.parse({ "@suaveplan/api-plugin-users": "^1" });
```
## Advanced Example
```ts
import { resolveInstallOrder } from "@suaveplan/api-manifest/dep-graph";
import { dependsOnSchema } from "@suaveplan/api-manifest/schema";
const deps = dependsOnSchema.parse(raw);
const order = resolveInstallOrder([
{ ...userManifest, dependsOn: deps },
...others,
]);
```
## API Reference
- `dependsOnSchema: z.ZodReadonly<z.ZodRecord<z.ZodString, z.ZodString>>`
- `DependsOn = z.infer<typeof dependsOnSchema>`
## Implementation Notes
The schema deliberately doesn't pre-validate range syntax — `resolveInstallOrder` round-trips every range through `satisfies()` and surfaces `MANIFEST.DEPENDENCY_VERSION_MISMATCH` on anything it can't parse, so the error envelope is identical for invalid ranges and unsatisfied ranges. This keeps the schema decoupled from the matcher's exact range subset. Duplicate keys in JSON resolve to last-wins per `JSON.parse`, and the dep-graph treats each entry independently.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,12 @@
import z from "zod";
/**
* Map of plugin-name → semver range. Used by the dep-graph resolver to
* compute install order, detect cycles, surface missing deps, and check
* version compatibility against the supplied set.
*/
export const dependsOnSchema = z
.record(z.string().min(1), z.string().min(1))
.readonly();
export type DependsOn = z.infer<typeof dependsOnSchema>;

View File

@@ -0,0 +1,48 @@
# `errorsSchema`
## Purpose
Plugin-error status-map block. Maps `PLUGIN.<DOMAIN>.<REASON>` codes to HTTP status numbers and optional redaction paths. The host's error middleware consults this map when rendering error envelopes so plugins get a stable wire shape across boot, reload, and version upgrades.
## Features
- Code regex enforces `PLUGIN.<UPPER>.<UPPER>` template
- Status codes restricted to 4xx and 5xx
- Redact entries are dot-paths into the error's context payload
- Defence-in-depth re-check at the validator layer surfaces every offender in one pass
## Basic Example
```ts
import { errorsSchema } from "@suaveplan/api-manifest/schema";
errorsSchema.parse({ statusMap: { "PLUGIN.X.Y": 409 } });
```
## Advanced Example
```ts
import { errorsSchema } from "@suaveplan/api-manifest/schema";
const errs = errorsSchema.parse({
statusMap: {
"PLUGIN.BILLING.SUBSCRIPTION_INACTIVE": 409,
"PLUGIN.BILLING.STRIPE_API_FAILURE": 502,
},
redact: { "PLUGIN.BILLING.STRIPE_API_FAILURE": ["details.stripeKey"] },
});
```
## API Reference
- `errorsSchema: z.ZodObject<{ statusMap?, redact? }>`
- `errorCodeRegex`, `errorPathSegmentRegex` — exported for cross-validator reuse.
- `Errors = z.infer<typeof errorsSchema>`
## Implementation Notes
`errorCodeRegex` is exported because the cross-field validator `validateErrorStatusMapShape` needs it too — splitting the regex would risk drift. Status numbers are validated to be integers in [400, 600) so the host can be sure it's safe to set them on the response status line. Redact paths use dot-separated identifiers; each segment is validated to be a valid JS identifier so nested-key lookups don't need to handle quoting.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,47 @@
import z from "zod";
/**
* Plugin-error status-map block. Codes follow the
* `PLUGIN.<DOMAIN>.<REASON>` template literal. Statuses are restricted
* to 4xx and 5xx — `validateErrorStatusMapShape` enforces the same
* invariant at the package boundary so consumers get a stable error code.
*/
export const errorCodeRegex = /^PLUGIN\.[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*$/;
export const errorPathSegmentRegex = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const errorsSchema = z.object({
statusMap: z
.record(
z.string().regex(errorCodeRegex),
z
.number()
.int()
.refine((n) => n >= 400 && n < 600, {
message: "status must be 4xx or 5xx",
})
)
.optional(),
redact: z
.record(
z.string().regex(errorCodeRegex),
z
.array(
z
.string()
.min(1)
.refine(
(p) =>
p
.split(".")
.every((s) =>
errorPathSegmentRegex.test(s)
),
{ message: "invalid dot-path" }
)
)
.readonly()
)
.optional(),
});
export type Errors = z.infer<typeof errorsSchema>;

View File

@@ -0,0 +1,44 @@
# `eventsSchema`
## Purpose
Event-bus topology declaration. Lists the subjects this plugin publishes and the subjects it subscribes to. Subjects follow the `domain.entity.verb` convention which the regex enforces — the host's bus cross-checks publish/subscribe pairs at boot for typo + drift surfacing.
## Features
- Topic regex enforces lower-snake_case dotted segments, ≥2 segments
- Optional `publishes` / `subscribes` readonly string arrays
- Empty event blocks are allowed (a plugin may neither publish nor subscribe)
- Cross-plugin pairing surface for the host's bus introspector
## Basic Example
```ts
import { eventsSchema } from "@suaveplan/api-manifest/schema";
eventsSchema.parse({ publishes: ["billing.invoice.created"] });
```
## Advanced Example
```ts
import { eventsSchema } from "@suaveplan/api-manifest/schema";
const events = eventsSchema.parse({
publishes: ["billing.invoice.created", "billing.payment.succeeded"],
subscribes: ["users.user.deleted"],
});
```
## API Reference
- `eventsSchema: z.ZodObject<{ publishes?, subscribes? }>`
- `Events = z.infer<typeof eventsSchema>`
## Implementation Notes
The topic regex (`^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$`) enforces at least one dot — single-segment topics are reserved for the bus's internal channels. Lower-case letters, digits, hyphens, and underscores are accepted inside segments; mixed-case is rejected to match the rest of the bus contract. Cross-plugin checking (does anything subscribe to what we publish?) lives in the host, not in this schema.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,22 @@
import z from "zod";
const TOPIC_RE = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/;
/**
* Event-bus topology declaration. Topics use the
* `domain.entity.verb` convention enforced by the regex below — the host
* cross-checks publish/subscribe pairs across the registered plugin set
* for typo + drift surfacing.
*/
export const eventsSchema = z.object({
publishes: z
.array(z.string().regex(TOPIC_RE, "invalid event topic"))
.readonly()
.optional(),
subscribes: z
.array(z.string().regex(TOPIC_RE, "invalid event topic"))
.readonly()
.optional(),
});
export type Events = z.infer<typeof eventsSchema>;

View File

@@ -0,0 +1,45 @@
# `graphqlSchema`
## Purpose
Optional GraphQL contribution block. When `contributes` is `true`, the host loads the SDL at `types` (path relative to the package root) and merges the resolvers exported from `resolversFrom` into the federated schema. The cross-field validator `validateOpenApiRequiresGraphqlOrRoutes` references this flag.
## Features
- `contributes` boolean discriminator
- Optional `types` path to an SDL file
- Optional `resolversFrom` path to a JS/TS module exporting `resolvers`
- Composable with `routes` and `openapi` blocks
## Basic Example
```ts
import { graphqlSchema } from "@suaveplan/api-manifest/schema";
graphqlSchema.parse({ contributes: false });
```
## Advanced Example
```ts
import { graphqlSchema } from "@suaveplan/api-manifest/schema";
const gql = graphqlSchema.parse({
contributes: true,
types: "graphql/types.graphql",
resolversFrom: "src/graphql.ts",
});
```
## API Reference
- `graphqlSchema: z.ZodObject<{ contributes, types?, resolversFrom? }>`
- `GraphqlBlock = z.infer<typeof graphqlSchema>`
## Implementation Notes
`contributes: false` is a legal state — it lets a plugin opt out of GraphQL while keeping the block present for tooling that wants a uniform shape. Paths are intentionally strings (not URLs or `node:fs`-resolved paths) because the host resolves them relative to the package root after install; baking resolution into the schema would couple it to a specific module-resolution strategy.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,14 @@
import z from "zod";
/**
* Optional GraphQL contribution block. When `contributes` is true the
* host loads the SDL at `types` and the resolvers exported from
* `resolversFrom` and merges them into the federated schema.
*/
export const graphqlSchema = z.object({
contributes: z.boolean(),
types: z.string().optional(),
resolversFrom: z.string().optional(),
});
export type GraphqlBlock = z.infer<typeof graphqlSchema>;

View File

@@ -0,0 +1,54 @@
# `identitySchema`
## Purpose
Zod schema for the identity fields of a plugin manifest — the keys that uniquely identify a plugin in the registry and pin its semver. Validates `name` against npm package-name rules and `version` against strict SemVer 2.0.0. Optional `$schema`, `displayName`, `description`, `license` fields round out the metadata block.
## Features
- NPM-compatible name regex (`@scope/pkg` optional, lower-case, hyphen/underscore allowed)
- Strict SemVer 2.0.0 validation including pre-release and build metadata
- Optional `$schema` URL for JSON-Schema-aware editors
- Optional `displayName`, `description`, `license` carry over to the admin UI
## Basic Example
```ts
import { identitySchema } from "@suaveplan/api-manifest/schema";
identitySchema.parse({
name: "@acme/billing-plugin",
version: "1.4.2",
});
```
## Advanced Example
```ts
import { identitySchema } from "@suaveplan/api-manifest/schema";
const result = identitySchema.safeParse({
name: "acme/billing",
version: "1.4",
});
if (!result.success) {
for (const issue of result.error.issues) {
console.error(issue.path, issue.message);
}
}
```
## API Reference
- `identitySchema: z.ZodObject<{ $schema?, name, version, displayName?, description?, license? }>`
- `IdentityFields = z.infer<typeof identitySchema>`
## Implementation Notes
Name validation matches the npm registry's regex (lower-case, no leading dot/underscore, optional `@scope/` prefix); 214-char max length matches npm's hard limit. Version regex implements SemVer 2.0.0 exactly — `MAJOR.MINOR.PATCH[-PRE][+BUILD]` — which is what `satisfies()` in the dep-graph module assumes; loose versions like `1.4` are rejected to keep the dep-graph comparator total. The identity block is merged into `pluginManifestSchema` rather than nested so the top-level keys match the JSON wire shape from design §3.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,33 @@
import z from "zod";
/**
* Identity fields of the plugin manifest. Together they uniquely identify
* a plugin in the registry and pin its semver.
*
* `name` matches npm package-name rules (lower-case, optional scope,
* hyphenated word segments). `version` is validated as strict
* `MAJOR.MINOR.PATCH[-pre][+build]` — host upgrade-detection and the
* `dependsOn` resolver both rely on `semver.compare`-like ordering.
*/
export const identitySchema = z.object({
$schema: z.string().url().optional(),
name: z
.string()
.min(1)
.max(214)
.regex(
/^(?:@[a-z0-9][a-z0-9-_]*\/)?[a-z0-9][a-z0-9-_.]*$/,
"name must be a valid npm package name"
),
version: z
.string()
.regex(
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/,
"version must be SemVer 2.0.0"
),
displayName: z.string().min(1).optional(),
description: z.string().optional(),
license: z.string().min(1).optional(),
});
export type IdentityFields = z.infer<typeof identitySchema>;

View File

@@ -0,0 +1,78 @@
export { type CompatFields, compatSchema } from "./compat.js";
export { type ConfigBlock, configSchema } from "./config.js";
export { type DatabaseBlock, databaseSchema } from "./database.js";
export { type DependsOn, dependsOnSchema } from "./depends-on.js";
export {
type Errors,
errorCodeRegex,
errorPathSegmentRegex,
errorsSchema,
} from "./errors.js";
export { type Events, eventsSchema } from "./events.js";
export { type GraphqlBlock, graphqlSchema } from "./graphql.js";
export { type IdentityFields, identitySchema } from "./identity.js";
export {
type Isolation,
isolationSchema,
type TrustLevel,
trustLevelSchema,
} from "./isolation.js";
export {
type CronJob,
cronJobSchema,
type Jobs,
jobsSchema,
type QueuedJob,
queuedJobSchema,
} from "./jobs.js";
export { type OpenApiBlock, openapiSchema } from "./openapi.js";
export {
type Budgets,
budgetsSchema,
type Permissions,
permissionsSchema,
} from "./permissions.js";
export {
type AssignableToApiTypes,
type PluginManifest,
pluginManifestSchema,
} from "./plugin-manifest.js";
export { type Routes, routesSchema } from "./routes.js";
export {
type OwnedSaga,
ownedSagaSchema,
type SagaStep,
type Sagas,
sagaStepSchema,
sagasSchema,
} from "./sagas.js";
export {
type Scope,
type Scopes,
scopeSchema,
scopesSchema,
} from "./scopes.js";
export {
type Secret,
type Secrets,
secretSchema,
secretsSchema,
} from "./secrets.js";
export {
type Sse,
type SseEndpoint,
sseEndpointSchema,
sseSchema,
} from "./sse.js";
export { type Telemetry, telemetrySchema } from "./telemetry.js";
export {
type TwoPhaseCommit,
twoPhaseCommitSchema,
} from "./two-phase-commit.js";
export { type Uninstall, uninstallSchema } from "./uninstall.js";
export {
type Websocket,
type Websockets,
websocketSchema,
websocketsSchema,
} from "./websockets.js";

View File

@@ -0,0 +1,46 @@
# `isolationSchema` + `trustLevelSchema`
## Purpose
Two Zod enum schemas covering the orthogonal isolation and trust axes of the manifest. Isolation determines how the host loads the plugin (in-process, worker-isolated, or sandboxed-with-permissions); trust level signals provenance independent of how isolation is enforced.
## Features
- Three discrete isolation tiers: `trusted`, `isolated`, `sandboxed`
- Three trust levels: `first-party`, `third-party`, `untrusted`
- Both inferred types are string-literal unions (no widening)
- Independent axes so a `trusted`+`third-party` combo is representable when an operator opts in
## Basic Example
```ts
import { isolationSchema, trustLevelSchema } from "@suaveplan/api-manifest/schema";
isolationSchema.parse("sandboxed");
trustLevelSchema.parse("first-party");
```
## Advanced Example
```ts
import { isolationSchema, type Isolation } from "@suaveplan/api-manifest/schema";
function needsBudgets(tier: Isolation): boolean {
return isolationSchema.parse(tier) === "sandboxed";
}
```
## API Reference
- `isolationSchema: z.ZodEnum<["trusted", "isolated", "sandboxed"]>`
- `trustLevelSchema: z.ZodEnum<["first-party", "third-party", "untrusted"]>`
- `Isolation`, `TrustLevel` — inferred type aliases.
## Implementation Notes
Trust level and isolation tier are intentionally orthogonal — a first-party plugin may still be run sandboxed for defence-in-depth, and a third-party plugin may run trusted when an operator has audited and signed it. The validator `validatePermissionsWhenSandboxed` couples isolation to the permissions block at the cross-field layer rather than the Zod schema. Both enums are closed unions — adding a new tier requires an spec change because every downstream consumer's discriminated union widens.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,14 @@
import z from "zod";
/** Three discrete isolation tiers chosen at install time. */
export const isolationSchema = z.enum(["trusted", "isolated", "sandboxed"]);
/** Trust level — provenance signal independent of isolation. */
export const trustLevelSchema = z.enum([
"first-party",
"third-party",
"untrusted",
]);
export type Isolation = z.infer<typeof isolationSchema>;
export type TrustLevel = z.infer<typeof trustLevelSchema>;

View File

@@ -0,0 +1,47 @@
# `jobsSchema`
## Purpose
Background-job block: cron schedules and queued workers. Handlers live in the code half of the manifest; this block is the declarative surface the host pre-registers so cron triggers and queue topics exist before plugin code runs.
## Features
- Cron expression validated against a permissive character regex
- Optional timezone per cron entry (host applies a default if absent)
- Optional `perTenant` flag for tenant-scoped cron
- Queued jobs carry an optional `maxAttempts` retry cap
## Basic Example
```ts
import { jobsSchema } from "@suaveplan/api-manifest/schema";
jobsSchema.parse({ cron: [{ name: "daily", expression: "0 0 * * *" }] });
```
## Advanced Example
```ts
import { jobsSchema } from "@suaveplan/api-manifest/schema";
const jobs = jobsSchema.parse({
cron: [
{ name: "reconcile", expression: "0 3 * * *", tz: "UTC", perTenant: true },
],
queued: [{ name: "send-receipt", maxAttempts: 5 }],
});
```
## API Reference
- `jobsSchema: z.ZodObject<{ cron?, queued? }>`
- `cronJobSchema`, `queuedJobSchema` — the building blocks.
- Types: `Jobs`, `CronJob`, `QueuedJob`.
## Implementation Notes
The cron expression regex permits the digits, asterisks, commas, slashes, and dashes that cover both 5-field and 6-field crontabs; the host's `@suaveplan/cron` parser is the source of truth for actual scheduling semantics, including L/W/# extensions. The schema's regex is a fast-fail surface, not the canonical parser. `perTenant: true` instructs the host's tenant fan-out to run one logical cron per tenant rather than a single host-wide cron.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,29 @@
import z from "zod";
const CRON_RE = /^[*0-9,/\-? ]+$/;
/**
* Background-job block: cron expressions and queued workers. Handler
* implementations live in the code half — this is the host pre-register
* surface.
*/
export const cronJobSchema = z.object({
name: z.string().min(1),
expression: z.string().regex(CRON_RE, "invalid cron expression").min(1),
tz: z.string().min(1).optional(),
perTenant: z.boolean().optional(),
});
export const queuedJobSchema = z.object({
name: z.string().min(1),
maxAttempts: z.number().int().positive().optional(),
});
export const jobsSchema = z.object({
cron: z.array(cronJobSchema).readonly().optional(),
queued: z.array(queuedJobSchema).readonly().optional(),
});
export type CronJob = z.infer<typeof cronJobSchema>;
export type QueuedJob = z.infer<typeof queuedJobSchema>;
export type Jobs = z.infer<typeof jobsSchema>;

View File

@@ -0,0 +1,45 @@
# `openapiSchema`
## Purpose
OpenAPI contribution block. When `contributes: true` the host emits a per-plugin OpenAPI document by introspecting the registered routes (and, if `graphql.contributes` is also true, the GraphQL schema). The cross-field validator `validateOpenApiRequiresGraphqlOrRoutes` enforces that there is actually something to document.
## Features
- `contributes` boolean discriminator
- Optional `tagDisplayName` for the rendered docs UI
- Optional `externalDocs` URL
- Cross-field coupling to `routes`/`graphql` enforced by a separate validator
## Basic Example
```ts
import { openapiSchema } from "@suaveplan/api-manifest/schema";
openapiSchema.parse({ contributes: false });
```
## Advanced Example
```ts
import { openapiSchema } from "@suaveplan/api-manifest/schema";
const api = openapiSchema.parse({
contributes: true,
tagDisplayName: "Billing & Subscriptions",
externalDocs: "https://docs.acme.io/billing",
});
```
## API Reference
- `openapiSchema: z.ZodObject<{ contributes, tagDisplayName?, externalDocs? }>`
- `OpenApiBlock = z.infer<typeof openapiSchema>`
## Implementation Notes
The `externalDocs` URL is checked with Zod's built-in URL validator, which rejects schemes other than http/https; that matches the OpenAPI 3.1 spec's expectations. `tagDisplayName` is rendered by the docs UI as the grouping label for this plugin's routes. The block is intentionally minimal — the host's OpenAPI emitter owns the actual schema generation.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,14 @@
import z from "zod";
/**
* OpenAPI contribution block. Requires at least one of `graphql.contributes`
* or `routes` to be present — enforced by
* `validateOpenApiRequiresGraphqlOrRoutes`.
*/
export const openapiSchema = z.object({
contributes: z.boolean(),
tagDisplayName: z.string().min(1).optional(),
externalDocs: z.string().url().optional(),
});
export type OpenApiBlock = z.infer<typeof openapiSchema>;

View File

@@ -0,0 +1,48 @@
# `permissionsSchema` + `budgetsSchema`
## Purpose
Zod schemas for the sandbox permissions block. `permissions.fs` and `permissions.fetch` are host-proxied allowlists honoured only when isolation is `sandboxed`; `permissions.budgets` carries CPU/memory/wall-clock caps for the per-request quota enforcer.
## Features
- Optional `fs` and `fetch` allowlists (readonly string arrays)
- `budgets.cpuPercent` clamped to 1..100
- `budgets.memoryMb` and `budgets.wallClockMs` positive integers
- Cross-field rule `validatePermissionsWhenSandboxed` enforces budgets-when-sandboxed at a separate layer
## Basic Example
```ts
import { permissionsSchema } from "@suaveplan/api-manifest/schema";
permissionsSchema.parse({ budgets: { cpuPercent: 25, memoryMb: 512 } });
```
## Advanced Example
```ts
import { permissionsSchema, budgetsSchema } from "@suaveplan/api-manifest/schema";
const budgets = budgetsSchema.parse({ cpuPercent: 25, memoryMb: 512, wallClockMs: 30000 });
const perms = permissionsSchema.parse({
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets,
});
```
## API Reference
- `permissionsSchema: z.ZodObject<{ fs?, fetch?, budgets? }>`
- `budgetsSchema: z.ZodObject<{ cpuPercent?, memoryMb?, wallClockMs? }>`
- `Permissions`, `Budgets` — inferred type aliases.
## Implementation Notes
The `fs` and `fetch` allowlists use plain strings rather than parsed globs/URLs to keep the schema decoupled from any specific matcher (`micromatch`, `globby`, etc.); the host plugin that enforces the sandbox chooses its own matcher. `cpuPercent` is capped at 100 so a misconfigured plugin can't request more than a full core. Every field is optional at the schema level; the `sandboxed`-tier requirement of having budgets at all is enforced at the cross-field validator layer.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,23 @@
import z from "zod";
/**
* Sandbox permissions block. Only honoured when `isolation === "sandboxed"`.
*
* The validator `validatePermissionsWhenSandboxed` enforces that
* `budgets` is present whenever isolation is sandboxed — Zod alone
* can't express that cross-field invariant.
*/
export const budgetsSchema = z.object({
cpuPercent: z.number().int().min(1).max(100).optional(),
memoryMb: z.number().int().min(1).optional(),
wallClockMs: z.number().int().min(1).optional(),
});
export const permissionsSchema = z.object({
fs: z.array(z.string()).readonly().optional(),
fetch: z.array(z.string()).readonly().optional(),
budgets: budgetsSchema.optional(),
});
export type Budgets = z.infer<typeof budgetsSchema>;
export type Permissions = z.infer<typeof permissionsSchema>;

View File

@@ -0,0 +1,45 @@
# `pluginManifestSchema` and `PluginManifest`
## Purpose
Composite Zod schema joining every per-key sub-schema into a single strict-mode object. The inferred `PluginManifest` type is structurally assignable to the `api-types` view of the same shape — `AssignableToApiTypes` is a compile-time fence that the type-system enforces during typecheck.
## Features
- Strict mode at the top level — unknown keys are rejected
- Every documented field from design §3 is covered
- Type-level fence to `@suaveplan/api-types/manifest` `PluginManifest`
- Single import point for the entire manifest contract
## Basic Example
```ts
import { pluginManifestSchema } from "@suaveplan/api-manifest/schema";
pluginManifestSchema.parse({ name: "x", version: "0.1.0", apiHost: "^1", isolation: "isolated", trustLevel: "first-party" });
```
## Advanced Example
```ts
import { pluginManifestSchema, type PluginManifest, type AssignableToApiTypes } from "@suaveplan/api-manifest/schema";
import type { PluginManifest as ApiTypesPluginManifest } from "@suaveplan/api-types/manifest";
const _fence: AssignableToApiTypes = true;
function asApiTypes(m: PluginManifest): ApiTypesPluginManifest { return m; }
```
## API Reference
- `pluginManifestSchema: z.ZodObject<...>.strict()`
- `PluginManifest = z.infer<typeof pluginManifestSchema>`
- `AssignableToApiTypes` — compile-time fence type.
## Implementation Notes
Strict mode at the top level is the host's drift-detection surface: any unknown top-level key (typoed `displayname` instead of `displayName`, or a future v2 field) is rejected with a `MANIFEST.PARSE_FAILED` error pointing at the offending path. Sub-objects use the Zod default (`.passthrough()` not applied, but no `.strict()` either) so future additive expansion inside a block remains possible without breaking existing consumers.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MAXIMAL_FIXTURE, MINIMAL_FIXTURE } from "../testing/conformance.js";
import {
type PluginManifest,
pluginManifestSchema,
} from "./plugin-manifest.js";
describe("pluginManifestSchema", () => {
it("minimal manifest parses", { timeout: 5000 }, () => {
const parsed = pluginManifestSchema.parse(MINIMAL_FIXTURE);
expect(parsed.name).toBe(MINIMAL_FIXTURE.name);
expect(parsed.version).toBe(MINIMAL_FIXTURE.version);
expect(parsed.apiHost).toBe(MINIMAL_FIXTURE.apiHost);
// optional fields stay absent (not defaulted)
expect("routes" in parsed).toBe(false);
});
it("maximal manifest round-trips every field", { timeout: 5000 }, () => {
const parsed: PluginManifest =
pluginManifestSchema.parse(MAXIMAL_FIXTURE);
expect(parsed.name).toBe(MAXIMAL_FIXTURE.name);
expect(parsed.sagas?.owns?.[0]?.name).toBe("create-customer");
expect(
parsed.errors?.statusMap?.["PLUGIN.BILLING.SUBSCRIPTION_INACTIVE"]
).toBe(409);
expect(parsed.uninstall?.dataPolicy).toBe("archive");
expect(parsed.telemetry?.counters?.[0]?.labels?.[0]).toBe("currency");
});
it("rejects unknown top-level keys (.strict())", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
badKey: 1,
});
expect(r.success).toBe(false);
if (!r.success) {
const flat = JSON.stringify(r.error.issues);
expect(flat.includes("badKey")).toBe(true);
}
});
it("rejects invalid version", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
version: "1.4",
});
expect(r.success).toBe(false);
});
it("rejects invalid name", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
name: "BadName!",
});
expect(r.success).toBe(false);
});
it("rejects routes prefix without leading slash", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
routes: { prefix: "billing" },
});
expect(r.success).toBe(false);
});
it("accepts websockets / sse / scopes / events arrays", {
timeout: 5000,
}, () => {
const parsed = pluginManifestSchema.parse({
...MINIMAL_FIXTURE,
websockets: [{ path: "/ws", auth: "x:read", heartbeatMs: 15000 }],
sse: [{ path: "/sse" }],
scopes: [{ name: "billing:read" }],
events: { publishes: ["a.b.c"] },
});
expect(parsed.websockets?.length).toBe(1);
expect(parsed.sse?.length).toBe(1);
});
it("rejects bad event topic", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
events: { publishes: ["NotDotted"] },
});
expect(r.success).toBe(false);
});
it("rejects bad secret name", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
secrets: [{ name: "lowercase", required: true }],
});
expect(r.success).toBe(false);
});
it("rejects bad scope name", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
scopes: [{ name: "no-colon" }],
});
expect(r.success).toBe(false);
});
it("rejects bad error code in statusMap", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
errors: { statusMap: { "lower.case.code": 409 } },
});
expect(r.success).toBe(false);
});
it("rejects status outside 4xx/5xx", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
errors: { statusMap: { "PLUGIN.X.Y": 200 } },
});
expect(r.success).toBe(false);
});
it("rejects redact path with bad segment", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
errors: { redact: { "PLUGIN.X.Y": ["bad-segment"] } },
});
expect(r.success).toBe(false);
});
it("rejects cron with disallowed chars", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
jobs: { cron: [{ name: "x", expression: "abc" }] },
});
expect(r.success).toBe(false);
});
it("rejects ws path without leading slash", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
websockets: [{ path: "live" }],
});
expect(r.success).toBe(false);
});
it("rejects sse path without leading slash", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
sse: [{ path: "feed" }],
});
expect(r.success).toBe(false);
});
it("rejects empty saga step list", { timeout: 5000 }, () => {
const r = pluginManifestSchema.safeParse({
...MINIMAL_FIXTURE,
sagas: { owns: [{ name: "x", steps: [] }] },
});
expect(r.success).toBe(false);
});
it("accepts wildcard apiHost", { timeout: 5000 }, () => {
const parsed = pluginManifestSchema.parse({
...MINIMAL_FIXTURE,
apiHost: "*",
});
expect(parsed.apiHost).toBe("*");
});
});

View File

@@ -0,0 +1,69 @@
import type { PluginManifest as ApiTypesPluginManifest } from "@suaveplan/api-types/manifest";
import type z from "zod";
import { compatSchema } from "./compat.js";
import { configSchema } from "./config.js";
import { databaseSchema } from "./database.js";
import { dependsOnSchema } from "./depends-on.js";
import { errorsSchema } from "./errors.js";
import { eventsSchema } from "./events.js";
import { graphqlSchema } from "./graphql.js";
import { identitySchema } from "./identity.js";
import { isolationSchema, trustLevelSchema } from "./isolation.js";
import { jobsSchema } from "./jobs.js";
import { openapiSchema } from "./openapi.js";
import { permissionsSchema } from "./permissions.js";
import { routesSchema } from "./routes.js";
import { sagasSchema } from "./sagas.js";
import { scopesSchema } from "./scopes.js";
import { secretsSchema } from "./secrets.js";
import { sseSchema } from "./sse.js";
import { telemetrySchema } from "./telemetry.js";
import { twoPhaseCommitSchema } from "./two-phase-commit.js";
import { uninstallSchema } from "./uninstall.js";
import { websocketsSchema } from "./websockets.js";
/**
* Composite Zod schema for the full plugin manifest. Strict mode is on
* at the top level so unknown keys are rejected — this is how we keep
* the contract from drifting silently as new fields land.
*
* Required fields per design §3: `name`, `version`, `apiHost`,
* `isolation`, `trustLevel`. Everything else is optional.
*/
export const pluginManifestSchema = identitySchema
.merge(compatSchema)
.extend({
isolation: isolationSchema,
trustLevel: trustLevelSchema,
permissions: permissionsSchema.optional(),
dependsOn: dependsOnSchema.optional(),
routes: routesSchema.optional(),
graphql: graphqlSchema.optional(),
websockets: websocketsSchema.optional(),
sse: sseSchema.optional(),
sagas: sagasSchema.optional(),
twoPhaseCommit: twoPhaseCommitSchema.optional(),
database: databaseSchema.optional(),
jobs: jobsSchema.optional(),
events: eventsSchema.optional(),
secrets: secretsSchema.optional(),
config: configSchema.optional(),
scopes: scopesSchema.optional(),
telemetry: telemetrySchema.optional(),
openapi: openapiSchema.optional(),
errors: errorsSchema.optional(),
uninstall: uninstallSchema.optional(),
})
.strict();
/** Inferred plugin-manifest type, structurally assignable to `api-types`. */
export type PluginManifest = z.infer<typeof pluginManifestSchema>;
/**
* Compile-time fence asserting `PluginManifest` (Zod-derived) is a
* subtype of the `api-types` view. Exported as a type so non-test
* coverage analysers don't flag it as dead code.
*/
export type AssignableToApiTypes = PluginManifest extends ApiTypesPluginManifest
? true
: never;

View File

@@ -0,0 +1,44 @@
# `routesSchema`
## Purpose
Validates the declarative HTTP-routes block. Carries the route `prefix` plus an optional `publicRoutes` array — routes the host mounts without auth gating. The handler implementations live in the code half of the manifest (`definePlugin({ routes })`); this block only captures what the host needs to know declaratively to mount + introspect routes before loading code.
## Features
- `prefix` must start with `/` — Zod regex rejects unprefixed segments
- `publicRoutes` entries must each start with `/`
- Public routes are optional — most plugins authenticate every route
- Inferred type is the same shape `api-types` exposes via `RouteManifest`
## Basic Example
```ts
import { routesSchema } from "@suaveplan/api-manifest/schema";
routesSchema.parse({ prefix: "/billing" });
```
## Advanced Example
```ts
import { routesSchema } from "@suaveplan/api-manifest/schema";
const routes = routesSchema.parse({
prefix: "/billing",
publicRoutes: ["/webhooks/stripe", "/health"],
});
```
## API Reference
- `routesSchema: z.ZodObject<{ prefix, publicRoutes? }>`
- `Routes = z.infer<typeof routesSchema>`
## Implementation Notes
Public-route paths are matched verbatim by the host's auth middleware; supporting glob patterns is intentionally deferred — the right place for that is the rate-limit / auth plugin, not the manifest. The prefix lives in the manifest rather than the code half so admin tooling can build the canonical URL map without loading any plugin code, which matters for sandboxed plugins where loading code is expensive.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,13 @@
import z from "zod";
/**
* HTTP routes block. The handler implementations live in the code half
* of the manifest (`src/index.ts`'s `definePlugin({ routes })`); this
* block only carries the declarative prefix + public-route allowlist.
*/
export const routesSchema = z.object({
prefix: z.string().min(1).regex(/^\//, "prefix must start with '/'"),
publicRoutes: z.array(z.string().regex(/^\//)).readonly().optional(),
});
export type Routes = z.infer<typeof routesSchema>;

View File

@@ -0,0 +1,51 @@
# `sagasSchema`
## Purpose
Saga ownership + participation block. `owns` lists the sagas this plugin orchestrates (each with a non-empty step list); `participatesIn` lists the sagas this plugin only contributes a step to. The cross-field validator `validateSagaOwnership` couples saga steps to `dependsOn` so a step's `plugin` reference must be either the manifest's own name or a declared dependency.
## Features
- Each owned saga has a name plus ≥1 steps
- Each step pins a `plugin`, `action`, and `compensate` handler name
- `participatesIn` lists the saga IDs this plugin contributes a step to
- Cross-plugin coupling enforced at the validator layer, not the schema
## Basic Example
```ts
import { sagasSchema } from "@suaveplan/api-manifest/schema";
sagasSchema.parse({ owns: [], participatesIn: ["@acme/onboarding/setup"] });
```
## Advanced Example
```ts
import { sagasSchema } from "@suaveplan/api-manifest/schema";
const sagas = sagasSchema.parse({
owns: [
{
name: "create-customer",
steps: [
{ plugin: "@acme/billing", action: "createSub", compensate: "cancelSub" },
],
},
],
});
```
## API Reference
- `sagasSchema: z.ZodObject<{ owns?, participatesIn? }>`
- `sagaStepSchema`, `ownedSagaSchema` — the building blocks.
- Types: `Sagas`, `OwnedSaga`, `SagaStep`.
## Implementation Notes
An owned saga MUST have at least one step (`min(1)`); a saga with zero steps is a degenerate state the host can't run, so we reject it at parse time rather than at boot. The step shape pins three handler names; the host resolves each handler name against the named plugin's exported saga-handler map at start. `participatesIn` is a string array of fully-qualified saga IDs (`<owner-plugin>/<saga-name>`) to avoid name collisions across plugins.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,26 @@
import z from "zod";
/**
* Saga ownership + participation block. Cross-field validation
* (`validateSagaOwnership`) enforces that every step's `plugin` is
* either the manifest's own name or appears in `dependsOn`.
*/
export const sagaStepSchema = z.object({
plugin: z.string().min(1),
action: z.string().min(1),
compensate: z.string().min(1),
});
export const ownedSagaSchema = z.object({
name: z.string().min(1),
steps: z.array(sagaStepSchema).min(1).readonly(),
});
export const sagasSchema = z.object({
owns: z.array(ownedSagaSchema).readonly().optional(),
participatesIn: z.array(z.string().min(1)).readonly().optional(),
});
export type SagaStep = z.infer<typeof sagaStepSchema>;
export type OwnedSaga = z.infer<typeof ownedSagaSchema>;
export type Sagas = z.infer<typeof sagasSchema>;

View File

@@ -0,0 +1,46 @@
# `scopesSchema`
## Purpose
AuthZ scope declarations. Each entry pins a `resource:action` name and an optional human-readable description. The host's permission system merges every plugin's scope list and surfaces the union to admin tooling for role-binding.
## Features
- `resource:action` regex (lower-case, hyphen/underscore allowed, exactly one colon)
- Optional `description` for the admin UI
- Readonly array discourages mutation by caller code
- Cross-plugin merging happens at the host layer
## Basic Example
```ts
import { scopesSchema } from "@suaveplan/api-manifest/schema";
scopesSchema.parse([{ name: "billing:read" }]);
```
## Advanced Example
```ts
import { scopesSchema } from "@suaveplan/api-manifest/schema";
const scopes = scopesSchema.parse([
{ name: "billing:read", description: "Read invoices and subscriptions" },
{ name: "billing:write", description: "Create/cancel subscriptions" },
{ name: "billing:admin", description: "Refunds and manual overrides" },
]);
```
## API Reference
- `scopeSchema: z.ZodObject<{ name, description? }>`
- `scopesSchema: z.ZodReadonly<z.ZodArray<typeof scopeSchema>>`
- `Scope`, `Scopes`.
## Implementation Notes
The regex enforces exactly one colon — wildcards (`billing:*`) are intentionally not allowed in declarations because they make role-binding ambiguous; the host's permission system supports wildcards in role grants instead. Descriptions are free-form strings; rendering is the admin UI's job. Duplicate names across plugins are handled at the host's merge layer.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,18 @@
import z from "zod";
const SCOPE_RE = /^[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*$/;
/**
* AuthZ scope declarations. Names follow `resource:action`. The host's
* permission system merges every plugin's scope list and surfaces them
* to admin tooling for role-binding.
*/
export const scopeSchema = z.object({
name: z.string().regex(SCOPE_RE, "scope must be `resource:action`"),
description: z.string().optional(),
});
export const scopesSchema = z.array(scopeSchema).readonly();
export type Scope = z.infer<typeof scopeSchema>;
export type Scopes = z.infer<typeof scopesSchema>;

View File

@@ -0,0 +1,46 @@
# `secretsSchema`
## Purpose
Declares the named secrets the plugin needs at boot. Names use SCREAMING_SNAKE_CASE so they map cleanly onto environment-variable conventions; the `required: true` flag tells the host to fail fast at boot when the value is missing rather than at first use.
## Features
- Names matched against `/^[A-Z][A-Z0-9_]*$/`
- `required` is a non-optional boolean (no implicit default)
- Readonly array of secret descriptors
- Source-of-truth for the host's secret-store adapter at boot
## Basic Example
```ts
import { secretsSchema } from "@suaveplan/api-manifest/schema";
secretsSchema.parse([{ name: "STRIPE_SECRET_KEY", required: true }]);
```
## Advanced Example
```ts
import { secretsSchema } from "@suaveplan/api-manifest/schema";
const secrets = secretsSchema.parse([
{ name: "STRIPE_SECRET_KEY", required: true },
{ name: "STRIPE_WEBHOOK_SECRET", required: true },
{ name: "STRIPE_PUBLISHABLE_KEY", required: false },
]);
```
## API Reference
- `secretSchema: z.ZodObject<{ name, required }>`
- `secretsSchema: z.ZodReadonly<z.ZodArray<typeof secretSchema>>`
- `Secret`, `Secrets`.
## Implementation Notes
Secret values never appear in the manifest — only the names do. The host's secret-store adapter resolves each name against its backing source (env, Bun.secrets, Vault, etc.) at boot. Making `required` non-optional is deliberate: a plugin author who has thought hard enough to declare a secret has thought hard enough to say whether the plugin can boot without it. Defaults at the schema layer would mask drift.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,20 @@
import z from "zod";
const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
/**
* Secret-name declarations. Host loads values from its secret store
* adapter (env, Bun.secrets, Vault, …) and fails fast when any
* `required: true` entry is missing at boot.
*/
export const secretSchema = z.object({
name: z
.string()
.regex(SECRET_NAME_RE, "secret name must be SCREAMING_SNAKE_CASE"),
required: z.boolean(),
});
export const secretsSchema = z.array(secretSchema).readonly();
export type Secret = z.infer<typeof secretSchema>;
export type Secrets = z.infer<typeof secretsSchema>;

View File

@@ -0,0 +1,44 @@
# `sseEndpointSchema` + `sseSchema`
## Purpose
Server-Sent-Events endpoint declarations. Mirrors the WebSocket block's shape minus the heartbeat/connection cap — SSE is half-duplex and the long-poll-style connection cost is lower so per-endpoint sizing isn't required at the manifest layer.
## Features
- Path must start with `/`
- Optional `auth` scope
- Optional subscription-subject list
- Composable with `websockets` — a plugin can ship both
## Basic Example
```ts
import { sseEndpointSchema } from "@suaveplan/api-manifest/schema";
sseEndpointSchema.parse({ path: "/feed" });
```
## Advanced Example
```ts
import { sseSchema } from "@suaveplan/api-manifest/schema";
const feeds = sseSchema.parse([
{ path: "/feed", auth: "feed:read", subscriptions: ["feed.item"] },
]);
```
## API Reference
- `sseEndpointSchema: z.ZodObject<{ path, auth?, subscriptions? }>`
- `sseSchema: z.ZodReadonly<z.ZodArray<typeof sseEndpointSchema>>`
- `SseEndpoint`, `Sse`.
## Implementation Notes
SSE is intentionally a separate block from WebSockets even though the host could synthesise one from the other — a plugin author typically picks the transport based on browser-support and proxy-traversal trade-offs, so making the choice explicit in the manifest makes the contract auditable. The host can still elect to emulate one over the other when capabilities are missing.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,13 @@
import z from "zod";
/** SSE endpoint declaration. */
export const sseEndpointSchema = z.object({
path: z.string().regex(/^\//, "path must start with '/'"),
auth: z.string().min(1).optional(),
subscriptions: z.array(z.string().min(1)).readonly().optional(),
});
export const sseSchema = z.array(sseEndpointSchema).readonly();
export type SseEndpoint = z.infer<typeof sseEndpointSchema>;
export type Sse = z.infer<typeof sseSchema>;

View File

@@ -0,0 +1,47 @@
# `telemetrySchema`
## Purpose
Telemetry-instrument declarations. The host pre-registers every named counter, histogram, and gauge so plugin code can emit observations without first round-tripping through a registration call. The optional `metricPrefix` and `spanPrefix` keep cross-plugin observability namespaces clean.
## Features
- Optional `metricPrefix` and `spanPrefix` strings
- `counters`, `histograms`, `gauges` are each optional readonly arrays of `{ name, labels? }`
- Labels are optional per-instrument (cardinality control is the operator's job)
- Instrument names are free-form strings — the schema doesn't enforce a dotted-segment convention
## Basic Example
```ts
import { telemetrySchema } from "@suaveplan/api-manifest/schema";
telemetrySchema.parse({ counters: [{ name: "requests_total" }] });
```
## Advanced Example
```ts
import { telemetrySchema } from "@suaveplan/api-manifest/schema";
const telem = telemetrySchema.parse({
metricPrefix: "billing.",
spanPrefix: "billing.",
counters: [{ name: "billing.invoices_finalised_total", labels: ["currency"] }],
histograms: [{ name: "billing.stripe.call_duration_ms", labels: ["operation"] }],
gauges: [{ name: "billing.outstanding_invoice_count" }],
});
```
## API Reference
- `telemetrySchema: z.ZodObject<{ metricPrefix?, spanPrefix?, counters?, histograms?, gauges? }>`
- `Telemetry = z.infer<typeof telemetrySchema>`
## Implementation Notes
Pre-registering instruments at boot is how the host avoids the registration-on-first-emit problem (race conditions across worker boundaries; cardinality explosions from a typo). Instrument names are validated for non-emptiness only; the actual prefix-merge convention is enforced by the host's OTel adapter rather than the schema.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,21 @@
import z from "zod";
/**
* Telemetry-instrument declarations. The host pre-registers each named
* counter/histogram/gauge so plugin code can emit without first
* round-tripping through a registration call.
*/
const instrumentSchema = z.object({
name: z.string().min(1),
labels: z.array(z.string().min(1)).readonly().optional(),
});
export const telemetrySchema = z.object({
metricPrefix: z.string().min(1).optional(),
spanPrefix: z.string().min(1).optional(),
counters: z.array(instrumentSchema).readonly().optional(),
histograms: z.array(instrumentSchema).readonly().optional(),
gauges: z.array(instrumentSchema).readonly().optional(),
});
export type Telemetry = z.infer<typeof telemetrySchema>;

View File

@@ -0,0 +1,44 @@
# `twoPhaseCommitSchema`
## Purpose
Two-phase-commit participation declaration. A plugin sets `participates: true` to opt into the host's 2PC coordinator; the host's capability checker verifies the `db.preparable` capability is present at boot before any 2PC transaction is attempted.
## Features
- Single-field block — boolean discriminator
- Coupled to the `db.preparable` capability at the host layer
- Plugins that don't need 2PC simply omit the block
- Designed for future expansion (timeout overrides, isolation hints)
## Basic Example
```ts
import { twoPhaseCommitSchema } from "@suaveplan/api-manifest/schema";
twoPhaseCommitSchema.parse({ participates: true });
```
## Advanced Example
```ts
import { twoPhaseCommitSchema, pluginManifestSchema } from "@suaveplan/api-manifest/schema";
const manifest = pluginManifestSchema.parse(raw);
if (manifest.twoPhaseCommit?.participates) {
// require db.preparable capability
}
```
## API Reference
- `twoPhaseCommitSchema: z.ZodObject<{ participates }>`
- `TwoPhaseCommit = z.infer<typeof twoPhaseCommitSchema>`
## Implementation Notes
The shape is intentionally minimal at v1 — the host owns the timeout, retry, and recovery policy globally, so the manifest only needs to declare opt-in. Future versions can extend the schema with per-plugin timeout overrides under the same key; `.strict()` on the composite manifest catches accidental misnamed fields ahead of any v2 migration.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,8 @@
import z from "zod";
/** 2PC participant declaration. Requires `db.preparable` capability when true. */
export const twoPhaseCommitSchema = z.object({
participates: z.boolean(),
});
export type TwoPhaseCommit = z.infer<typeof twoPhaseCommitSchema>;

View File

@@ -0,0 +1,44 @@
# `uninstallSchema`
## Purpose
Uninstall data-handling policy. Three modes: `archive` (move rows to a prefixed shadow table so they survive removal), `drop` (destroy the plugin's schema), `keep` (leave data in place for a later reinstall). The optional `archiveTablePrefix` lets a plugin pin the shadow-table prefix the host uses.
## Features
- Closed enum of three policies
- Optional `archiveTablePrefix` (host falls back to a default when absent)
- Required at the manifest level only when uninstall semantics matter to the plugin
- Designed for forward expansion (retention windows, audit-log emission)
## Basic Example
```ts
import { uninstallSchema } from "@suaveplan/api-manifest/schema";
uninstallSchema.parse({ dataPolicy: "keep" });
```
## Advanced Example
```ts
import { uninstallSchema } from "@suaveplan/api-manifest/schema";
const uninstall = uninstallSchema.parse({
dataPolicy: "archive",
archiveTablePrefix: "_archived_billing_",
});
```
## API Reference
- `uninstallSchema: z.ZodObject<{ dataPolicy, archiveTablePrefix? }>`
- `Uninstall = z.infer<typeof uninstallSchema>`
## Implementation Notes
`archive` is the default policy most plugins should pick — it preserves the plugin's data behind a prefix so the host's admin tool can resurrect it during a re-install. `drop` is irreversible and the host emits a destructive-action audit-log entry whenever it's selected. `keep` leaves data in place for plugins where ownership is unclear (e.g. shared tables across plugin versions); the host emits a warning in this case.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,13 @@
import z from "zod";
/**
* Uninstall data-handling policy. `archive` moves rows to a prefixed
* table so they survive plugin removal; `drop` destroys the schema;
* `keep` leaves data in place for a later reinstall.
*/
export const uninstallSchema = z.object({
dataPolicy: z.enum(["archive", "drop", "keep"]),
archiveTablePrefix: z.string().min(1).optional(),
});
export type Uninstall = z.infer<typeof uninstallSchema>;

View File

@@ -0,0 +1,44 @@
# `websocketSchema` + `websocketsSchema`
## Purpose
Validates the WebSocket endpoint declarations. Each entry pins a path, an optional auth scope, the set of subjects the connection can subscribe to, and the per-tenant connection cap plus heartbeat interval the host should enforce.
## Features
- Path must start with `/`
- Optional `auth` scope (resource:action format applied at manifest-merge time)
- Subscriptions are an optional readonly subject array
- Positive-integer `maxConnectionsPerTenant` and `heartbeatMs`
## Basic Example
```ts
import { websocketSchema } from "@suaveplan/api-manifest/schema";
websocketSchema.parse({ path: "/live" });
```
## Advanced Example
```ts
import { websocketsSchema } from "@suaveplan/api-manifest/schema";
const sockets = websocketsSchema.parse([
{ path: "/live/orders", auth: "orders:read", maxConnectionsPerTenant: 100, heartbeatMs: 30000 },
]);
```
## API Reference
- `websocketSchema: z.ZodObject<{ path, auth?, subscriptions?, maxConnectionsPerTenant?, heartbeatMs? }>`
- `websocketsSchema: z.ZodReadonly<z.ZodArray<typeof websocketSchema>>`
- `Websocket`, `Websockets`.
## Implementation Notes
Per-tenant connection caps live in the manifest (rather than the host's global config) because they're a plugin-specific contract — a chat plugin needs a higher cap than a billing dashboard plugin. The heartbeat interval is in milliseconds rather than seconds to match the rest of the host's timing surface; values below ~5000 are technically legal but typically only used in tests.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,19 @@
import z from "zod";
/**
* WebSocket endpoint declaration. The host opens the socket on the
* declared path, gates access via the optional `auth` scope, and routes
* subscription deliveries through the named subjects.
*/
export const websocketSchema = z.object({
path: z.string().regex(/^\//, "path must start with '/'"),
auth: z.string().min(1).optional(),
subscriptions: z.array(z.string().min(1)).readonly().optional(),
maxConnectionsPerTenant: z.number().int().positive().optional(),
heartbeatMs: z.number().int().positive().optional(),
});
export const websocketsSchema = z.array(websocketSchema).readonly();
export type Websocket = z.infer<typeof websocketSchema>;
export type Websockets = z.infer<typeof websocketsSchema>;

View File

@@ -0,0 +1,54 @@
# `runManifestConformance` and fixtures
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reusable conformance harness for downstream tooling (admin CLI, IDE plugin) that ships its own `parseManifest` implementation. The harness drives any parser through four fixture buckets — minimal-valid, maximal-valid, missing-name-invalid, unknown-key-invalid — and returns a per-bucket boolean report.
## Features
- Two valid fixtures: minimal (the smallest legal manifest) and maximal (every documented field)
- Two invalid fixtures: missing-name and unknown-key (the strict-mode rejection case)
- `runManifestConformance` returns a discriminated `ConformanceReport`
- Fixtures exported individually so tests can build variants without copy/paste
## Basic Example
```ts
import { runManifestConformance } from "@suaveplan/api-manifest/testing";
import { parseManifest } from "@suaveplan/api-manifest/parse";
const report = runManifestConformance(parseManifest);
```
## Advanced Example
```ts
import { runManifestConformance, MAXIMAL_FIXTURE, type ConformanceFixtures } from "@suaveplan/api-manifest/testing";
const fixtures: ConformanceFixtures = {
minimal: { name: "x", version: "0.1.0", apiHost: "^2", isolation: "trusted", trustLevel: "first-party" },
maximal: MAXIMAL_FIXTURE,
invalidMissingName: {},
invalidUnknownKey: { ...MAXIMAL_FIXTURE, badKey: 1 },
};
const report = runManifestConformance(parseManifest, fixtures);
```
## API Reference
- `runManifestConformance(parse, fixtures?): ConformanceReport`
- `MINIMAL_FIXTURE`, `MAXIMAL_FIXTURE`, `DEFAULT_FIXTURES`.
- `ConformanceFixtures`, `ConformanceReport`.
## Implementation Notes
The harness deliberately returns a structured report rather than throwing — downstream consumers may want to render the report into their own CI surface (a custom matcher, a JUnit emitter, etc.) without coupling to a specific assertion library. The `MAXIMAL_FIXTURE` is the same shape used in the package's own integration tests so any downstream parser that passes the harness is checked against the same canonical example.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseManifest } from "../parse/parse.js";
import {
DEFAULT_FIXTURES,
MAXIMAL_FIXTURE,
MINIMAL_FIXTURE,
runManifestConformance,
} from "./conformance.js";
describe("runManifestConformance", () => {
it("package's own parser passes every fixture bucket", {
timeout: 5000,
}, () => {
const r = runManifestConformance(parseManifest);
expect(r.minimalOk).toBe(true);
expect(r.maximalOk).toBe(true);
expect(r.invalidMissingNameRejected).toBe(true);
expect(r.invalidUnknownKeyRejected).toBe(true);
});
it("honours caller-supplied fixtures", { timeout: 5000 }, () => {
const r = runManifestConformance(parseManifest, DEFAULT_FIXTURES);
expect(r.minimalOk).toBe(true);
expect(r.maximalOk).toBe(true);
});
it("fixtures are typed manifests", { timeout: 5000 }, () => {
expect(MINIMAL_FIXTURE.name).toBe("minimal-plugin");
expect(MAXIMAL_FIXTURE.name).toBe("@acme/billing-plugin");
});
});

View File

@@ -0,0 +1,164 @@
import type { PluginManifest } from "../schema/plugin-manifest.js";
/**
* Fixtures the conformance harness drives every parser through. A
* downstream consumer (admin CLI, IDE plugin) that swaps in its own
* parser passes when both the minimal and maximal fixtures round-trip
* with structurally equivalent results.
*/
export interface ConformanceFixtures {
readonly minimal: PluginManifest;
readonly maximal: PluginManifest;
readonly invalidMissingName: unknown;
readonly invalidUnknownKey: unknown;
}
export const MINIMAL_FIXTURE: PluginManifest = {
name: "minimal-plugin",
version: "0.1.0",
apiHost: "^1",
isolation: "isolated",
trustLevel: "first-party",
};
export const MAXIMAL_FIXTURE: PluginManifest = {
name: "@acme/billing-plugin",
version: "1.4.2",
displayName: "Billing",
description: "Subscriptions and invoicing.",
license: "MIT",
apiHost: "^1",
requiredCapabilities: ["router.streaming", "db.transactions"],
optionalCapabilities: ["events.bounded-queue"],
isolation: "sandboxed",
trustLevel: "first-party",
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets: { cpuPercent: 25, memoryMb: 512, wallClockMs: 30000 },
},
dependsOn: { "@suaveplan/api-plugin-users": "^1" },
routes: { prefix: "/billing", publicRoutes: ["/webhooks/stripe"] },
graphql: { contributes: true, types: "graphql/types.graphql" },
websockets: [
{
path: "/live/orders",
auth: "orders:read",
subscriptions: ["orders.created"],
maxConnectionsPerTenant: 100,
heartbeatMs: 30000,
},
],
sse: [{ path: "/feed", auth: "feed:read" }],
sagas: {
owns: [
{
name: "create-customer",
steps: [
{
plugin: "@acme/billing-plugin",
action: "createSubscription",
compensate: "cancelSubscription",
},
],
},
],
participatesIn: ["@acme/onboarding/full-tenant-setup"],
},
twoPhaseCommit: { participates: true },
database: {
required: true,
preferredEngine: "postgres",
compatibleEngines: ["postgres", "sqlite"],
schemaVersion: 7,
migrationsDir: "migrations",
tenantIsolation: "row-level",
tablePrefix: "billing_",
},
jobs: {
cron: [
{ name: "reconcile-invoices", expression: "0 3 * * *", tz: "UTC" },
],
queued: [{ name: "send-receipt", maxAttempts: 5 }],
},
events: {
publishes: ["billing.invoice.created"],
subscribes: ["users.user.deleted"],
},
secrets: [{ name: "STRIPE_SECRET_KEY", required: true }],
config: { schemaPath: "config.schema.json", defaults: { trialDays: 14 } },
scopes: [{ name: "billing:read", description: "Read invoices" }],
telemetry: {
metricPrefix: "billing.",
spanPrefix: "billing.",
counters: [
{
name: "billing.invoices_finalised_total",
labels: ["currency"],
},
],
},
openapi: { contributes: true, tagDisplayName: "Billing" },
errors: {
statusMap: { "PLUGIN.BILLING.SUBSCRIPTION_INACTIVE": 409 },
redact: { "PLUGIN.BILLING.STRIPE_API_FAILURE": ["details.stripeKey"] },
},
uninstall: {
dataPolicy: "archive",
archiveTablePrefix: "_archived_billing_",
},
};
export const DEFAULT_FIXTURES: ConformanceFixtures = {
minimal: MINIMAL_FIXTURE,
maximal: MAXIMAL_FIXTURE,
invalidMissingName: { version: "0.1.0", apiHost: "^1" },
invalidUnknownKey: {
name: "x",
version: "0.1.0",
apiHost: "^1",
isolation: "isolated",
trustLevel: "first-party",
badKey: 1,
},
};
/**
* Reusable conformance harness. Pass any `parse` implementation
* (typically the package's own `parseManifest`) and the harness
* exercises the four fixture buckets. Returns the per-fixture result
* for the caller to assert on.
*/
export function runManifestConformance(
parse: (input: unknown) => PluginManifest,
fixtures: ConformanceFixtures = DEFAULT_FIXTURES
): ConformanceReport {
return {
minimalOk: tryParse(parse, fixtures.minimal),
maximalOk: tryParse(parse, fixtures.maximal),
invalidMissingNameRejected: !tryParse(
parse,
fixtures.invalidMissingName
),
invalidUnknownKeyRejected: !tryParse(parse, fixtures.invalidUnknownKey),
};
}
export interface ConformanceReport {
readonly minimalOk: boolean;
readonly maximalOk: boolean;
readonly invalidMissingNameRejected: boolean;
readonly invalidUnknownKeyRejected: boolean;
}
function tryParse(
parse: (input: unknown) => PluginManifest,
input: unknown
): boolean {
try {
parse(input);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,8 @@
export {
type ConformanceFixtures,
type ConformanceReport,
DEFAULT_FIXTURES,
MAXIMAL_FIXTURE,
MINIMAL_FIXTURE,
runManifestConformance,
} from "./conformance.js";

View File

@@ -0,0 +1,5 @@
export { validateErrorStatusMapShape } from "./validate-error-status-map-shape.js";
export { validateOpenApiRequiresGraphqlOrRoutes } from "./validate-openapi-requires-graphql-or-routes.js";
export { validatePermissionsWhenSandboxed } from "./validate-permissions-when-sandboxed.js";
export { validateSagaOwnership } from "./validate-saga-ownership.js";
export type { ValidationResult } from "./validation-result.types.js";

View File

@@ -0,0 +1,49 @@
# `validateErrorStatusMapShape`
## Purpose
Defence-in-depth re-check of the `errors.statusMap` shape. The Zod schema already rejects malformed entries; this validator collects every offender (rather than fail-fast on the first) so admin tooling can render the full diagnostic in one pass — operators get a complete fix list instead of fix-one-rerun-find-next.
## Features
- Walks every map entry — no early exit
- Surfaces code-format violations separately from status-range violations
- Empty / absent map is a no-op success
- Reuses `errorCodeRegex` from the schema layer to avoid drift
## Basic Example
```ts
import { validateErrorStatusMapShape, parseManifest } from "@suaveplan/api-manifest";
validateErrorStatusMapShape(parseManifest(raw));
```
## Advanced Example
```ts
import { validateErrorStatusMapShape } from "@suaveplan/api-manifest/validators";
import { parseManifest } from "@suaveplan/api-manifest/parse";
const manifest = parseManifest(raw);
const check = validateErrorStatusMapShape(manifest);
if (!check.ok) {
for (const o of (check.error.context?.offenders as { code: string; status: number; reason: string }[])) {
console.error(o.code, o.status, o.reason);
}
}
```
## API Reference
- `validateErrorStatusMapShape(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.INVALID_ERROR_STATUS_MAP` with `context.offenders: { code, status, reason }[]`.
## Implementation Notes
Because the Zod schema rejects ill-shaped entries up front, the only way this validator ever finds an offender is via direct programmatic construction (skipping `parseManifest`) — but that path is real (the host builds plugin manifests in tests, the registry seeds them, etc.). The validator's batch-mode contract means it remains the canonical check at the install gate even when the schema has already run.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,40 @@
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import { errorCodeRegex } from "../schema/errors.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import type { ValidationResult } from "./validation-result.types.js";
/**
* Defence-in-depth re-check of the `errors.statusMap` shape. The Zod
* schema already rejects malformed entries; this validator collects
* every offender (rather than fail-fast on the first) so admin tooling
* can render the full diagnostic in one pass.
*/
export function validateErrorStatusMapShape(
manifest: PluginManifest
): ValidationResult {
const map = manifest.errors?.statusMap;
if (!map) return { ok: true };
const offenders: { code: string; status: number; reason: string }[] = [];
for (const [code, status] of Object.entries(map)) {
if (!errorCodeRegex.test(code)) {
offenders.push({
code,
status,
reason: "code must match PLUGIN.<DOMAIN>.<REASON>",
});
continue;
}
if (!Number.isInteger(status) || status < 400 || status >= 600) {
offenders.push({ code, status, reason: "status must be 4xx/5xx" });
}
}
if (offenders.length === 0) return { ok: true };
return {
ok: false,
error: new ManifestError(
MANIFEST_CODES.INVALID_ERROR_STATUS_MAP,
"manifest.invalid_error_status_map",
{ context: { offenders } }
),
};
}

View File

@@ -0,0 +1,46 @@
# `validateOpenApiRequiresGraphqlOrRoutes`
## Purpose
Rejects manifests that declare an OpenAPI contribution but expose neither HTTP routes nor a GraphQL contribution. The OpenAPI emitter needs a surface to document; without one the emission step would produce an empty document that wastes admin-UI real estate.
## Features
- No-op success when `openapi.contributes` is false or absent
- Accepts either `routes` (any prefix) or `graphql.contributes === true`
- Stable `MANIFEST.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES` code
- Manifest name surfaced in the error context
## Basic Example
```ts
import { validateOpenApiRequiresGraphqlOrRoutes, parseManifest } from "@suaveplan/api-manifest";
const r = validateOpenApiRequiresGraphqlOrRoutes(parseManifest(raw));
if (!r.ok) throw r.error;
```
## Advanced Example
```ts
import { validateOpenApiRequiresGraphqlOrRoutes } from "@suaveplan/api-manifest/validators";
import { parseManifest } from "@suaveplan/api-manifest/parse";
const manifest = parseManifest(raw);
const check = validateOpenApiRequiresGraphqlOrRoutes(manifest);
if (!check.ok) console.warn("OpenAPI contrib will be empty:", manifest.name);
```
## API Reference
- `validateOpenApiRequiresGraphqlOrRoutes(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES`.
## Implementation Notes
GraphQL contribution is detected by `graphql.contributes === true` rather than mere presence of the block; a `{ contributes: false }` block is a legal opt-out state. Routes contribution is detected by mere presence — any prefix counts because the host will introspect handlers at boot. The check is intentionally cheap: a single boolean expression over four readily-available fields.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,25 @@
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import type { ValidationResult } from "./validation-result.types.js";
/**
* OpenAPI contributions require something to document — either a
* GraphQL contribution or an HTTP routes block. A manifest that asks
* the host to emit OpenAPI without exposing any surface is a misconfig.
*/
export function validateOpenApiRequiresGraphqlOrRoutes(
manifest: PluginManifest
): ValidationResult {
if (!manifest.openapi?.contributes) return { ok: true };
const hasRoutes = manifest.routes !== undefined;
const hasGraphql = manifest.graphql?.contributes === true;
if (hasRoutes || hasGraphql) return { ok: true };
return {
ok: false,
error: new ManifestError(
MANIFEST_CODES.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES,
"manifest.openapi_requires_graphql_or_routes",
{ context: { name: manifest.name } }
),
};
}

View File

@@ -0,0 +1,48 @@
# `validatePermissionsWhenSandboxed`
## Purpose
Enforces that a `sandboxed`-tier plugin declares `permissions.budgets`. Without budgets the host can't enforce CPU/memory/wall-clock quotas, and the sandbox guarantees would be a stronger claim than the runtime actually delivers. This is the canonical case where Zod alone can't capture the invariant — the requirement spans two top-level keys.
## Features
- No-op success for non-sandboxed isolation tiers
- Single-shot validation — fails iff isolation is sandboxed and budgets is missing
- Stable `MANIFEST.SANDBOX_REQUIRES_BUDGETS` code
- Manifest name surfaced in the error context for admin tooling
## Basic Example
```ts
import { validatePermissionsWhenSandboxed, parseManifest } from "@suaveplan/api-manifest";
const r = validatePermissionsWhenSandboxed(parseManifest(raw));
if (!r.ok) throw r.error;
```
## Advanced Example
```ts
import { validatePermissionsWhenSandboxed, type ValidationResult } from "@suaveplan/api-manifest/validators";
import { parseManifest } from "@suaveplan/api-manifest/parse";
function install(raw: unknown): void {
const manifest = parseManifest(raw);
const checks: ValidationResult[] = [validatePermissionsWhenSandboxed(manifest)];
for (const c of checks) if (!c.ok) throw c.error;
}
```
## API Reference
- `validatePermissionsWhenSandboxed(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.SANDBOX_REQUIRES_BUDGETS` with `context.name`.
## Implementation Notes
`permissions.budgets` is the host's hook for the quota enforcer — values can be partial (only `cpuPercent` set, no `memoryMb`) and the enforcer applies host defaults to missing fields. The validator only requires the block to be present, not for every sub-field to be populated; that lets a plugin opt into CPU caps without committing to a specific memory cap. The non-sandboxed-tier no-op return is important for the install gate's batch-validation path.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,24 @@
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import type { ValidationResult } from "./validation-result.types.js";
/**
* Sandboxed-tier plugins MUST declare `permissions.budgets`. Without
* budgets the host can't enforce CPU/memory/wall-clock caps and the
* sandbox guarantees would be a stronger claim than the runtime
* actually delivers.
*/
export function validatePermissionsWhenSandboxed(
manifest: PluginManifest
): ValidationResult {
if (manifest.isolation !== "sandboxed") return { ok: true };
if (manifest.permissions?.budgets !== undefined) return { ok: true };
return {
ok: false,
error: new ManifestError(
MANIFEST_CODES.SANDBOX_REQUIRES_BUDGETS,
"manifest.sandbox_requires_budgets",
{ context: { name: manifest.name } }
),
};
}

View File

@@ -0,0 +1,46 @@
# `validateSagaOwnership`
## Purpose
Cross-field validator that rejects sagas whose step list references a plugin neither owned by this manifest nor declared as a dependency. Catches typos and missing `dependsOn` entries at parse time rather than at runtime saga dispatch, which is exactly the kind of drift that's expensive to debug in production.
## Features
- Returns `{ ok: false }` with the full list of offending step references
- Treats the manifest's own `name` as a self-reference
- Walks every owned saga's step list, not just the first
- Empty `sagas.owns` is a no-op success
## Basic Example
```ts
import { validateSagaOwnership, parseManifest } from "@suaveplan/api-manifest";
const result = validateSagaOwnership(parseManifest(raw));
if (!result.ok) throw result.error;
```
## Advanced Example
```ts
import { validateSagaOwnership, parseManifest } from "@suaveplan/api-manifest";
const manifest = parseManifest(raw);
const result = validateSagaOwnership(manifest);
if (!result.ok) {
console.error("offenders", result.error.context?.offenders);
}
```
## API Reference
- `validateSagaOwnership(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.INVALID_SAGA_OWNERSHIP` with `context.offenders: { saga, step, plugin }[]`.
## Implementation Notes
The validator collects every offender in one pass instead of fail-fast on the first; admin tooling needs the full list to render a single diagnostic dialog rather than make the operator fix-rerun-fix-rerun. `participatesIn` is intentionally not cross-checked here — that's a host-level concern because the owning plugin lives in a different manifest and the host has the full set. This validator is bounded by the size of the local manifest only.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,41 @@
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import type { ValidationResult } from "./validation-result.types.js";
/**
* Reject sagas whose steps reference a `plugin` that is neither the
* manifest's own name nor declared in `dependsOn`. This catches typos
* + missing dep declarations long before the host attempts to dispatch
* the saga at runtime.
*/
export function validateSagaOwnership(
manifest: PluginManifest
): ValidationResult {
const owned = manifest.sagas?.owns;
if (!owned || owned.length === 0) return { ok: true };
const declared = new Set<string>([manifest.name]);
if (manifest.dependsOn) {
for (const dep of Object.keys(manifest.dependsOn)) declared.add(dep);
}
const offenders: { saga: string; step: string; plugin: string }[] = [];
for (const saga of owned) {
for (const step of saga.steps) {
if (!declared.has(step.plugin)) {
offenders.push({
saga: saga.name,
step: step.action,
plugin: step.plugin,
});
}
}
}
if (offenders.length === 0) return { ok: true };
return {
ok: false,
error: new ManifestError(
MANIFEST_CODES.INVALID_SAGA_OWNERSHIP,
"manifest.invalid_saga_ownership",
{ context: { offenders } }
),
};
}

View File

@@ -0,0 +1,10 @@
import type { ManifestError } from "../error-codes/error-codes.js";
/**
* Discriminated validator-result envelope. Validators never throw —
* they return this shape so callers can collect every cross-field
* failure in a single pass rather than fail-fast on the first.
*/
export type ValidationResult =
| { readonly ok: true }
| { readonly ok: false; readonly error: ManifestError };

View File

@@ -0,0 +1,229 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import { validateErrorStatusMapShape } from "./validate-error-status-map-shape.js";
import { validateOpenApiRequiresGraphqlOrRoutes } from "./validate-openapi-requires-graphql-or-routes.js";
import { validatePermissionsWhenSandboxed } from "./validate-permissions-when-sandboxed.js";
import { validateSagaOwnership } from "./validate-saga-ownership.js";
describe("validateSagaOwnership", () => {
it("rejects step referencing undeclared dependency", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
sagas: {
owns: [
{
name: "s1",
steps: [
{
plugin: "@acme/unknown",
action: "a",
compensate: "c",
},
],
},
],
},
};
const r = validateSagaOwnership(m);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.INVALID_SAGA_OWNERSHIP);
}
});
it("accepts step naming the manifest itself", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
sagas: {
owns: [
{
name: "s1",
steps: [
{
plugin: MINIMAL_FIXTURE.name,
action: "a",
compensate: "c",
},
],
},
],
},
};
expect(validateSagaOwnership(m).ok).toBe(true);
});
it("accepts step naming a declared dependency", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
dependsOn: { "@acme/dep": "^1" },
sagas: {
owns: [
{
name: "s1",
steps: [
{
plugin: "@acme/dep",
action: "a",
compensate: "c",
},
],
},
],
},
};
expect(validateSagaOwnership(m).ok).toBe(true);
});
it("no-op when sagas absent", { timeout: 5000 }, () => {
expect(validateSagaOwnership(MINIMAL_FIXTURE).ok).toBe(true);
});
it("no-op when owns is empty", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
sagas: { owns: [] },
};
expect(validateSagaOwnership(m).ok).toBe(true);
});
});
describe("validatePermissionsWhenSandboxed", () => {
it("sandboxed isolation requires permissions.budgets", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
isolation: "sandboxed",
};
const r = validatePermissionsWhenSandboxed(m);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.SANDBOX_REQUIRES_BUDGETS);
}
});
it("ok when sandboxed with budgets", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
isolation: "sandboxed",
permissions: { budgets: { cpuPercent: 25 } },
};
expect(validatePermissionsWhenSandboxed(m).ok).toBe(true);
});
it("no-op for non-sandboxed tiers", { timeout: 5000 }, () => {
expect(validatePermissionsWhenSandboxed(MINIMAL_FIXTURE).ok).toBe(true);
});
});
describe("validateOpenApiRequiresGraphqlOrRoutes", () => {
it("ok when not contributing", { timeout: 5000 }, () => {
expect(validateOpenApiRequiresGraphqlOrRoutes(MINIMAL_FIXTURE).ok).toBe(
true
);
});
it("fails when contributing without routes or graphql", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
};
const r = validateOpenApiRequiresGraphqlOrRoutes(m);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(
MANIFEST_CODES.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES
);
}
});
it("ok with routes", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
routes: { prefix: "/x" },
};
expect(validateOpenApiRequiresGraphqlOrRoutes(m).ok).toBe(true);
});
it("ok with graphql.contributes=true", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
graphql: { contributes: true },
};
expect(validateOpenApiRequiresGraphqlOrRoutes(m).ok).toBe(true);
});
it("fails when openapi contributes and graphql.contributes=false", {
timeout: 5000,
}, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
openapi: { contributes: true },
graphql: { contributes: false },
};
expect(validateOpenApiRequiresGraphqlOrRoutes(m).ok).toBe(false);
});
});
describe("validateErrorStatusMapShape", () => {
it("no-op when statusMap absent", { timeout: 5000 }, () => {
expect(validateErrorStatusMapShape(MINIMAL_FIXTURE).ok).toBe(true);
});
it("ok when every entry is well-formed", { timeout: 5000 }, () => {
const m: PluginManifest = {
...MINIMAL_FIXTURE,
errors: { statusMap: { "PLUGIN.A.B": 409 } },
};
expect(validateErrorStatusMapShape(m).ok).toBe(true);
});
it("collects code-shape offenders", { timeout: 5000 }, () => {
// bypass Zod by passing a manually built object
const bad = {
...MINIMAL_FIXTURE,
errors: {
statusMap: {
"lower.case.code": 409,
},
},
} as unknown as PluginManifest;
const r = validateErrorStatusMapShape(bad);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.code).toBe(MANIFEST_CODES.INVALID_ERROR_STATUS_MAP);
const off = r.error.context?.offenders as unknown[];
expect(off.length).toBeGreaterThan(0);
}
});
it("collects status-range offenders", { timeout: 5000 }, () => {
const bad = {
...MINIMAL_FIXTURE,
errors: {
statusMap: { "PLUGIN.A.B": 200 },
},
} as unknown as PluginManifest;
const r = validateErrorStatusMapShape(bad);
expect(r.ok).toBe(false);
});
it("rejects non-integer status", { timeout: 5000 }, () => {
const bad = {
...MINIMAL_FIXTURE,
errors: {
statusMap: { "PLUGIN.A.B": 4.5 as unknown as number },
},
} as unknown as PluginManifest;
const r = validateErrorStatusMapShape(bad);
expect(r.ok).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG = join(__dirname, "..", "package.json");
const SRC = join(__dirname, "..", "src");
describe("@suaveplan/api-manifest package shape", () => {
it("depends on the expected set of workspace + catalog packages", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PKG, "utf8")) as {
dependencies: Record<string, string>;
};
const deps = Object.keys(pkg.dependencies).sort();
expect(deps).toEqual([
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
]);
});
it("every src file uses default-import z (no named import)", {
timeout: 5000,
}, () => {
const offenders: string[] = [];
walk(SRC, (file) => {
if (!file.endsWith(".ts")) return;
const content = readFileSync(file, "utf8");
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(content)) {
offenders.push(file);
}
});
expect(offenders).toEqual([]);
});
it("root barrel has no export-star", { timeout: 5000 }, () => {
const content = readFileSync(join(SRC, "index.ts"), "utf8");
expect(/export\s*\*/.test(content)).toBe(false);
});
});
function walk(dir: string, fn: (file: string) => void): void {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, fn);
else fn(p);
}
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as cap from "../src/capability-resolver/index.js";
import * as depGraph from "../src/dep-graph/index.js";
import * as errorCodes from "../src/error-codes/index.js";
import * as hash from "../src/hash/index.js";
import * as root from "../src/index.js";
import * as parse from "../src/parse/index.js";
import * as schema from "../src/schema/index.js";
import * as testing from "../src/testing/index.js";
import * as validators from "../src/validators/index.js";
describe("every documented subpath is importable", () => {
it("subpath barrels expose their public surface", { timeout: 5000 }, () => {
expect(typeof schema.pluginManifestSchema.parse).toBe("function");
expect(typeof parse.parseManifest).toBe("function");
expect(typeof parse.parseManifestSafe).toBe("function");
expect(typeof validators.validateSagaOwnership).toBe("function");
expect(typeof validators.validateOpenApiRequiresGraphqlOrRoutes).toBe(
"function"
);
expect(typeof validators.validatePermissionsWhenSandboxed).toBe(
"function"
);
expect(typeof validators.validateErrorStatusMapShape).toBe("function");
expect(typeof depGraph.resolveInstallOrder).toBe("function");
expect(typeof depGraph.satisfies).toBe("function");
expect(typeof cap.resolveCapabilities).toBe("function");
expect(typeof hash.hashManifest).toBe("function");
expect(typeof hash.canonicalJson).toBe("function");
expect(typeof errorCodes.MANIFEST_CODES).toBe("object");
expect(typeof testing.runManifestConformance).toBe("function");
expect(typeof testing.MINIMAL_FIXTURE).toBe("object");
expect(typeof testing.MAXIMAL_FIXTURE).toBe("object");
// root barrel
expect(typeof root.parseManifest).toBe("function");
expect(typeof root.MANIFEST_CODES).toBe("object");
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,50 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-manifest",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"schema/index": "src/schema/index.ts",
"parse/index": "src/parse/index.ts",
"validators/index": "src/validators/index.ts",
"dep-graph/index": "src/dep-graph/index.ts",
"capability-resolver/index": "src/capability-resolver/index.ts",
"hash/index": "src/hash/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});