Initial Commit
This commit is contained in:
245
openspec/specs/api-types/spec.md
Normal file
245
openspec/specs/api-types/spec.md
Normal file
@@ -0,0 +1,245 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: api-types
|
||||
|
||||
**Package**: `@suaveplan/api-types`
|
||||
**Status**: SHIPPED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) `api-types` is types-only, so verification is type-level — `expectTypeOf` assertions in `*.spec-conformance.test.ts` files.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Types-only build with zero runtime
|
||||
|
||||
The package SHALL ship only TypeScript declarations. Every emitted leaf `dist/<subpath>/index.js` MUST be within the empty-module budget (≤ 200 bytes); the root barrel `dist/index.js` is permitted up to 4 KB to accommodate namespace re-exports (`export * as X from "..."`). No `dist/*.js` file MUST contain runtime declarations (`function`, `class`, `const`/`let`/`var` bindings). The package MUST declare zero runtime `dependencies` and zero `peerDependencies`.
|
||||
|
||||
#### Scenario: byte-size invariant pinned
|
||||
|
||||
- **WHEN** `bunx turbo build --filter=@suaveplan/api-types` completes
|
||||
- **THEN** `dist/index.js` exists AND every leaf `dist/<subpath>/index.js` is ≤ 200 bytes AND `dist/index.js` is ≤ 4096 bytes AND no `dist/**/*.js` contains a runtime declaration
|
||||
|
||||
#### Scenario: no runtime deps
|
||||
|
||||
- **WHEN** the test loads `package.json`
|
||||
- **THEN** `dependencies` is empty `{}` AND `peerDependencies` is empty or absent
|
||||
|
||||
**Verified by:** `tests/byte-size.test.ts::"dist/index.js stays under 64 bytes"` AND `tests/dep-graph.test.ts::"declares zero runtime dependencies"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Principal and tenant contract
|
||||
|
||||
The package SHALL export `Principal`, `PrincipalId`, `Scope`, `Claims`, `TenantId`, and `TenantContext` types. `Principal` SHALL carry an `id: PrincipalId`, a `scopes: readonly Scope[]`, an optional `tenantId?: TenantId`, and a `claims: Claims` record. `TenantId` SHALL be a branded `string` so plain strings cannot be silently substituted for tenant identifiers.
|
||||
|
||||
#### Scenario: Principal shape is fixed at the type level
|
||||
|
||||
- **WHEN** a value of type `Principal` is constructed
|
||||
- **THEN** TypeScript requires `id`, `scopes`, `claims` AND rejects extra unknown properties under `exactOptionalPropertyTypes`
|
||||
|
||||
#### Scenario: TenantId is brand-distinct from string
|
||||
|
||||
- **WHEN** a plain `string` is passed where `TenantId` is expected
|
||||
- **THEN** TypeScript reports a type error
|
||||
|
||||
**Verified by:** `src/_typetests/principal.spec-conformance.test.ts::"Principal requires id/scopes/claims and forbids excess"` AND `src/_typetests/tenant.spec-conformance.test.ts::"TenantId is brand-distinct from string"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin context separation
|
||||
|
||||
The package SHALL export `PluginContext<Caps>` and `RequestContext<Caps>`. `RequestContext` SHALL extend `PluginContext` (modulo the differently-typed `db` and `capabilities` slots) and add `request`, `principal`, `tenantId`, `tenant`, `requestId`, `span`. The `db` slot — exposed BOTH as a top-level context field AND as a capability-slice entry when `db` is declared in `Caps` — MUST be typed as `AdminDatabaseLike | null` on `PluginContext` and `TenantScopedDatabaseLike | null` on `RequestContext`. The two types MUST NOT be mutually assignable so an admin-DB consumer cannot accidentally accept a tenant-scoped one without an explicit cast. `null` SHALL be the runtime value when the host has no configured DB adapter, OR — for `RequestContext` only — when the tenant resolver chain returned no tenant on this request.
|
||||
|
||||
`RequestContext.tenant` SHALL be typed `TenantContext | null` and `RequestContext.tenantId` SHALL be typed `TenantId | null`. The nullable union is required because the `foundation-fix-gap-5-default-tenant` change made `defaultTenant: null` the host default — the resolver chain may legitimately miss on auth-optional routes, and the runtime value is then `null` on both fields. Handlers consuming tenant-bound capabilities MUST null-check OR declare `auth: "required"` on the route so the pipeline rejects the request before the handler runs. `JobContext.tenant` / `JobContext.tenantId` and `ConnectionContext.tenant` / `ConnectionContext.tenantId` SHALL remain non-nullable — per-tenant jobs and connection-scoped contexts only exist after a tenant has been resolved.
|
||||
|
||||
Both context flavours SHALL also expose the orchestration-level `secrets: SecretsLike` slot directly — the slot is always present at the type level (the host injects a default when no adapter is configured) and is independent of the capability map. `JobContext` and `ConnectionContext` SHALL likewise expose a tenant-scoped `db: TenantScopedDatabaseLike | null` top-level field AND inherit `secrets` through the same Omit-based extension.
|
||||
|
||||
#### Scenario: db slot has a different type per context
|
||||
|
||||
- **WHEN** a value of type `PluginContext<{db: true}>` and a value of type `RequestContext<{db: true}>` are compared
|
||||
- **THEN** `expectTypeOf<PluginContext<...>.capabilities.db>().not.toEqualTypeOf<RequestContext<...>.capabilities.db>()` passes
|
||||
|
||||
#### Scenario: capability map drives capability-slice field availability
|
||||
|
||||
- **WHEN** `PluginContext<{db: true, "auth.session": true}>` is constructed
|
||||
- **THEN** `ctx.capabilities.db` and `ctx.capabilities["auth.session"]` are required AND `ctx.capabilities.events` is NOT present in the type (unless `"events"` is also declared)
|
||||
|
||||
#### Scenario: secrets slot is always present on every context
|
||||
|
||||
- **WHEN** a plugin's lifecycle hook destructures `{ secrets } = ctx` where `ctx: PluginContext<readonly []>`
|
||||
- **THEN** `secrets` is typed as `SecretsLike` and resolves at runtime to whichever adapter the host configured (or the `EmptySecrets` default)
|
||||
|
||||
#### Scenario: top-level db is null-permissive
|
||||
|
||||
- **WHEN** a host is booted without an `adapters.db` slot
|
||||
- **THEN** `PluginContext.db === null` AND `RequestContext.db === null` at runtime AND the type system requires every consumer to null-check before use
|
||||
|
||||
#### Scenario: RequestContext.tenant is null-permissive after gap-5
|
||||
|
||||
- **WHEN** a route is reached with no tenant resolved AND no `defaultTenant` configured
|
||||
- **THEN** `RequestContext.tenant === null` AND `RequestContext.tenantId === null` at runtime AND the type system requires every handler to null-check or short-circuit via `auth: "required"` before reading tenant-bound fields
|
||||
|
||||
**Verified by:** `src/_typetests/plugin-context.spec-conformance.test.ts::"db slot type differs between PluginContext and RequestContext"` AND `src/_typetests/plugin-context.spec-conformance.test.ts::"capability map drives ctx field set"` AND host integration in `@suaveplan/api-host`'s `src/host/host.test.ts::"ctx.db === null when adapters.db is undefined"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Lifecycle state enum is complete
|
||||
|
||||
The package SHALL export `PluginState` as a string union including every state required by the lifecycle FSM in design doc §4: `"uninstalled" | "installing" | "installed" | "starting" | "started" | "rolled_back" | "crashed" | "stopping" | "stopped" | "uninstalling" | "install_failed" | "start_failed" | "uninstall_failed"`. A `PluginTransition` type MAY name the (from, to, trigger) tuples but is not required to enumerate them at the type level.
|
||||
|
||||
#### Scenario: every documented state is in the union
|
||||
|
||||
- **WHEN** `expectTypeOf<PluginState>()` is checked against each literal
|
||||
- **THEN** every state from §4 is present AND no extra states beyond §4 are present
|
||||
|
||||
**Verified by:** `src/_typetests/lifecycle.spec-conformance.test.ts::"PluginState union matches design doc §4 exactly"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Isolation tier enum
|
||||
|
||||
The package SHALL export `IsolationTier = "trusted" | "isolated" | "sandboxed"` and `TrustLevel = "first-party" | "third-party" | "untrusted"`. Both are simple string unions.
|
||||
|
||||
#### Scenario: isolation tier union has exactly three members
|
||||
|
||||
- **WHEN** `expectTypeOf<IsolationTier>()` is checked
|
||||
- **THEN** the union is exactly the three literals
|
||||
|
||||
**Verified by:** `src/_typetests/manifest.spec-conformance.test.ts::"IsolationTier has exactly three members"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Capability map is type-driven
|
||||
|
||||
The package SHALL export `CapabilityIdRegistry` as an extensible `interface` whose keys form the open `CapabilityId` union (`type CapabilityId = keyof CapabilityIdRegistry`). The foundation registry SHALL include 26 alphabetised entries: `"auth.provider"`, `"auth.session"`, `"db"`, `"db.preparable"`, `"db.transactions"`, `"db.unsafe-queries"`, `"events"`, `"events.bounded-queue"`, `"graphql"`, `"jobs"`, `"logger"`, `"openapi.generation"`, `"router"`, `"router.blue-green"`, `"router.streaming"`, `"runtime.isolation.sandbox"`, `"runtime.isolation.worker"`, `"saga.cross-tenant"`, `"saga.participant"`, `"secrets"`, `"secrets.bun"`, `"sse"`, `"tenant.provider"`, `"twopc.participant"`, `"ws"`, `"ws.broadcast-cross-tenant"`. A `CapabilityMap` lookup type SHALL map each `CapabilityId` to its capability shape, falling back to `unknown` when a third-party registry extension does not also extend `CapabilityShapeMap`. Third-party packages SHALL be able to contribute new ids via `declare module "@suaveplan/api-types/capabilities" { interface CapabilityIdRegistry { "my.cap": true } }` without forking api-types.
|
||||
|
||||
#### Scenario: CapabilityId is open via registry extension
|
||||
|
||||
- **WHEN** a downstream package writes `declare module "@suaveplan/api-types/capabilities" { interface CapabilityIdRegistry { "my.cap": true } }`
|
||||
- **THEN** `"my.cap"` is assignable to `CapabilityId` everywhere it is imported
|
||||
|
||||
#### Scenario: CapabilityId union is alphabetically ordered
|
||||
|
||||
- **WHEN** the foundation registry keys are listed
|
||||
- **THEN** they are alphabetised (stylistic invariant for readability; enforced by a Biome-like lint test)
|
||||
|
||||
#### Scenario: CapabilityMap returns the correct shape per id
|
||||
|
||||
- **WHEN** `expectTypeOf<CapabilityMap["db"]>()` is checked
|
||||
- **THEN** it equals `AdminDatabaseLike` (in PluginContext) or `TenantScopedDatabaseLike` (in RequestContext) — both shapes are reachable from the map lookup
|
||||
|
||||
**Verified by:** `src/_typetests/capabilities.spec-conformance.test.ts::"CapabilityId union exposes the documented members"` AND `src/_typetests/capabilities.spec-conformance.test.ts::"CapabilityId is alphabetised (stylistic invariant)"` AND `src/_typetests/capabilities.spec-conformance.test.ts::"CapabilityMap[db] resolves to AdminDatabaseLike"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ErrorCode is a constrained template literal
|
||||
|
||||
The package SHALL export `ErrorCode` as the template literal type `` `PLUGIN.${Uppercase<string>}.${Uppercase<string>}` ``. Plain strings that don't match this shape SHALL NOT be assignable to `ErrorCode`.
|
||||
|
||||
#### Scenario: well-formed codes are assignable
|
||||
|
||||
- **WHEN** `const c: ErrorCode = "PLUGIN.BILLING.INVALID_INVOICE"`
|
||||
- **THEN** TypeScript accepts the assignment
|
||||
|
||||
#### Scenario: malformed codes are rejected
|
||||
|
||||
- **WHEN** `const c: ErrorCode = "BILLING.INVALID_INVOICE"` (missing `PLUGIN.` prefix)
|
||||
- **THEN** TypeScript reports a type error
|
||||
|
||||
**Verified by:** `src/_typetests/errors.spec-conformance.test.ts::"ErrorCode requires PLUGIN.<UPPER>.<UPPER> shape"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Worker-proxy type witnesses every capability
|
||||
|
||||
The package SHALL export `WorkerProxyOf<T>` — a type-level transformation that converts a capability's method signatures from sync/async-direct into `MessagePort`-marshalled async signatures (return types wrapped in `Promise`, transferable streams supported). The transformation MUST preserve method names, parameter shapes (excluding non-cloneable values), and overload arity. It MUST exclude private/internal members.
|
||||
|
||||
#### Scenario: proxy type is structurally derived
|
||||
|
||||
- **WHEN** `expectTypeOf<WorkerProxyOf<AdminDatabaseLike>>()` is checked
|
||||
- **THEN** every method on `AdminDatabaseLike` is present on the proxy with the same name AND its return type is `Promise<R>` (wrapping the original `R` or `Promise<R>`)
|
||||
|
||||
#### Scenario: non-cloneable parameter types surface a marker
|
||||
|
||||
- **WHEN** a method takes a parameter whose type is not structured-clone-safe (e.g. a function)
|
||||
- **THEN** the proxy's method either rejects that parameter at the type level OR substitutes a clearly-named marker type (`NotCloneable<T>`)
|
||||
|
||||
**Verified by:** `src/_typetests/worker-rpc.spec-conformance.test.ts::"WorkerProxyOf preserves method names and wraps return type"` AND `src/_typetests/worker-rpc.spec-conformance.test.ts::"WorkerProxyOf flags non-cloneable parameters"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Rollback journal entry shape
|
||||
|
||||
The package SHALL export `RollbackJournalEntry`:
|
||||
|
||||
```ts
|
||||
interface RollbackJournalEntry {
|
||||
specifier: string; // file URL with ?v=N suffix
|
||||
manifestHash: string; // sha256:...
|
||||
moduleRef: WeakRef<unknown> | null; // nullable after GC
|
||||
migrationCursor: number; // schemaVersion at the time
|
||||
capturedAt: string; // ISO-8601
|
||||
version: string; // from manifest
|
||||
}
|
||||
```
|
||||
|
||||
And `RollbackJournal` SHALL be a ring buffer interface with `push(entry)`, `peek(n)`, `length`, and `clear()` operations. The buffer depth is a runtime constructor parameter (default 3).
|
||||
|
||||
#### Scenario: RollbackJournalEntry has every documented field
|
||||
|
||||
- **WHEN** `expectTypeOf<RollbackJournalEntry>()` is checked against the shape above
|
||||
- **THEN** every field is present with the documented type AND `moduleRef` is correctly typed as nullable
|
||||
|
||||
**Verified by:** `src/_typetests/rollback-journal.spec-conformance.test.ts::"RollbackJournalEntry shape matches design doc §8.1a"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports are tree-shake-friendly
|
||||
|
||||
Every category (`principal`, `tenant`, `manifest`, `router`, `capabilities`, `plugin-context`, `lifecycle`, `errors`, `auth`, `tenant-resolver`, `secrets`, `db`, `graphql`, `ws`, `sse`, `saga`, `twopc`, `worker-rpc`, `instruments`, `rollback-journal`) SHALL be reachable via its own subpath export so consumers can import only what they need. The root barrel (`@suaveplan/api-types`) SHALL re-export every subpath via explicit named re-exports (no `export *`) per CLAUDE.md rule 10.
|
||||
|
||||
#### Scenario: every documented subpath resolves
|
||||
|
||||
- **WHEN** the test imports each subpath individually
|
||||
- **THEN** every import succeeds at type-check time
|
||||
|
||||
#### Scenario: root barrel uses no `export *`
|
||||
|
||||
- **WHEN** the test reads `src/index.ts`
|
||||
- **THEN** the file contains no `export *` statements (namespace re-exports `export * as X` are allowed)
|
||||
|
||||
**Verified by:** `src/_typetests/subpaths.spec-conformance.test.ts::"every documented subpath is importable"` AND `tests/dep-graph.test.ts::"root barrel has no export-star"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tenant-resolver contract
|
||||
|
||||
The package SHALL export `TenantResolver` and `TenantResolverChain` types from `./tenant-resolver`. `TenantResolver` SHALL declare `{ id: string; resolve(request: Request): Promise<TenantContext | null> }`. `TenantResolverChain` SHALL declare `{ resolve(request: Request): Promise<TenantContext | null>; resolvers: readonly TenantResolver[] }` — first-non-null wins.
|
||||
|
||||
The package SHALL NOT export a `TenantProvider` type or any tenant-enumeration contract. The tenant enumeration provider used by per-tenant job fan-out is owned by `@suaveplan/api-tenant-provider`. api-types owns the per-request resolver contract; api-tenant-provider owns the enumeration contract. This separation keeps api-types at zero runtime/zero deps and lets the enumeration contract evolve independently.
|
||||
|
||||
#### Scenario: tenant-resolver barrel exports only the resolver contract
|
||||
|
||||
- **WHEN** the `./tenant-resolver` subpath barrel is imported
|
||||
- **THEN** only `TenantResolver` and `TenantResolverChain` are exposed; no `TenantProvider` symbol is reachable from api-types
|
||||
|
||||
**Verified by:** `src/_typetests/subpaths.spec-conformance.test.ts::"subpath barrels expose the documented symbols"` AND `tests/byte-size.test.ts::"every published src file is under budget"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Router contract
|
||||
|
||||
The package SHALL export the abstract `Router` interface from `./router`. `Router` SHALL be generic in two type parameters: `Caps extends CapabilityRequirements = CapabilityRequirements` (the plugin's declared capability set) and `Options extends DefaultRouteOptions = DefaultRouteOptions` (the per-route option block the concrete adapter accepts). `DefaultRouteOptions` SHALL be the base contract every concrete router adapter MUST accept: `{ auth?: string | null; operationId?: string; summary?: string; description?: string; timeoutMs?: number }`. The package SHALL export `RouteOptions` as a type alias of `DefaultRouteOptions` for source compatibility. `RouteMatch`, `RouterSlot`, and `BlueGreenSlot` SHALL thread the same `Options` type parameter.
|
||||
|
||||
Concrete adapters (e.g. `@suaveplan/api-router`) supply their own `Options` extending `DefaultRouteOptions` with adapter-specific policy switches. Plugins authored against the abstract `Router` (no `Options` provided) get `DefaultRouteOptions` and remain portable across adapters.
|
||||
|
||||
#### Scenario: Router defaults Options to DefaultRouteOptions
|
||||
|
||||
- **WHEN** `Router<readonly ["db"]>` is referenced without an `Options` argument
|
||||
- **THEN** every verb method (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`) accepts `opts: DefaultRouteOptions`
|
||||
|
||||
#### Scenario: Router accepts a widened Options superset
|
||||
|
||||
- **WHEN** a concrete adapter declares `interface MyAdapterOptions extends DefaultRouteOptions { rateLimit?: { rpm: number } }`
|
||||
- **THEN** `Router<readonly ["db"], MyAdapterOptions>` accepts `{ auth: "...", rateLimit: { rpm: 60 } }` on every verb method
|
||||
|
||||
**Verified by:** `src/_typetests/subpaths.spec-conformance.test.ts::"subpath barrels expose the documented symbols"` AND `packages/api-stack/api/api-router/src/route-options/route-options.spec-conformance.test.ts::"api-types-shaped objects parse successfully"`
|
||||
Reference in New Issue
Block a user