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,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);
});
});