Initial Commit
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-example (api-plugin-example)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-example`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. This spec doubles as an integration-test specification — every requirement names a concrete integration `it(...)`.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Plugin deploys cleanly under every isolation tier
|
||||
|
||||
The example plugin SHALL boot to `started` state under `isolation: "trusted"`, `isolation: "isolated"`, `isolation: "sandboxed"` (vm), AND `isolation: "sandboxed"` (wasm) configurations. The full integration suite runs in each.
|
||||
|
||||
#### Scenario: trusted-tier boot
|
||||
|
||||
- WHEN the host installs the plugin with `isolation: "trusted"`
|
||||
- THEN the plugin reaches `started` AND a request to `/example/health` returns 200
|
||||
|
||||
#### Scenario: isolated-tier boot
|
||||
|
||||
- WHEN the host installs the plugin with `isolation: "isolated"`
|
||||
- THEN the worker thread starts AND a request to `/example/health` returns 200
|
||||
|
||||
#### Scenario: sandboxed-vm boot
|
||||
|
||||
- WHEN the host installs the plugin with `isolation: "sandboxed"` and `host.defaultSandbox = "vm"`
|
||||
- THEN the plugin runs inside `api-sandbox-vm` AND a request to `/example/health` returns 200
|
||||
|
||||
#### Scenario: sandboxed-wasm boot
|
||||
|
||||
- WHEN the host installs the plugin with `isolation: "sandboxed"` and `host.defaultSandbox = "wasm"`
|
||||
- THEN the plugin runs inside `api-sandbox-wasm` AND a request to `/example/health` returns 200
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"plugin boots and serves /example/health in <mode>"` (parameterised over four modes)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: REST surface covers every Section 6 pipeline step
|
||||
|
||||
The plugin SHALL expose routes that exercise every step of the per-request pipeline: public, private+RBAC-gated, validated, streaming, idempotent, rate-limited.
|
||||
|
||||
#### Scenario: public route bypasses auth
|
||||
|
||||
- WHEN a request hits `/example/public/ping`
|
||||
- THEN response is 200 with no `auth.session` resolution required
|
||||
|
||||
#### Scenario: private RBAC-gated route enforces scope
|
||||
|
||||
- WHEN a request hits `/example/invoices` with a principal lacking `example:read`
|
||||
- THEN response is 403 with `code === "RBAC_MISSING_SCOPE"`
|
||||
|
||||
#### Scenario: validated body rejects malformed payload
|
||||
|
||||
- WHEN a request hits `/example/invoices` with `body.amountCents` missing
|
||||
- THEN response is 400 with the Zod-validated error envelope
|
||||
|
||||
#### Scenario: streaming route streams chunks
|
||||
|
||||
- WHEN a request hits `/example/stream/large-report`
|
||||
- THEN response uses chunked transfer-encoding AND every chunk is delivered before the handler returns
|
||||
|
||||
#### Scenario: idempotency replays original response
|
||||
|
||||
- WHEN two requests with the same `Idempotency-Key` hit `/example/invoices`
|
||||
- THEN the second response is byte-identical to the first AND only one DB row is created
|
||||
|
||||
#### Scenario: rate-limited route returns 429 with Retry-After
|
||||
|
||||
- WHEN burst is exceeded on `/example/rate-limited`
|
||||
- THEN response is 429 with `Retry-After` set
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"REST surface — public bypasses auth"` AND `...::"REST surface — RBAC denies missing scope"` AND `...::"REST surface — Zod rejects malformed body"` AND `...::"REST surface — streaming delivers chunked"` AND `...::"REST surface — idempotency replays response"` AND `...::"REST surface — rate-limited returns 429 with Retry-After"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: GraphQL surface — types, resolvers, federation, subscription
|
||||
|
||||
The plugin SHALL contribute GraphQL types AND resolvers AND a subscription channel. A sibling test plugin SHALL be installed in the integration test to exercise federation.
|
||||
|
||||
#### Scenario: federated query crosses plugins
|
||||
|
||||
- WHEN a GraphQL query touches types from both `api-plugin-example` and the sibling plugin
|
||||
- THEN the result contains both AND no field is `null` due to federation failure
|
||||
|
||||
#### Scenario: GraphQL subscription delivers events
|
||||
|
||||
- WHEN a subscription to `exampleInvoiceCreated` is open AND an invoice is created via REST
|
||||
- THEN the subscription delivers exactly one message
|
||||
|
||||
**Verified by:** `tests/integration/graphql-federation.integration.test.ts::"federated query crosses plugins"` AND `tests/integration/graphql-federation.integration.test.ts::"GraphQL subscription delivers exampleInvoiceCreated"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WebSocket surface — subscription fan-out single + multi-host
|
||||
|
||||
The plugin SHALL expose `/example/ws` with subscription fan-out. The integration test exercises single-host fan-out AND a multi-host topology via the broker adapter.
|
||||
|
||||
#### Scenario: single-host fan-out delivers to every subscriber
|
||||
|
||||
- WHEN two subscribers connect to `/example/ws` and the plugin publishes `example.invoice.created`
|
||||
- THEN both subscribers receive the message exactly once
|
||||
|
||||
#### Scenario: multi-host fan-out via broker
|
||||
|
||||
- WHEN the broker is wired across two host instances AND a subscriber connects to host A
|
||||
- THEN a publish on host B reaches the subscriber on host A
|
||||
|
||||
**Verified by:** `tests/integration/ws-broadcast.integration.test.ts::"single-host fan-out delivers to every subscriber"` AND `tests/integration/ws-broadcast.integration.test.ts::"multi-host fan-out via broker"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SSE surface with Last-Event-Id resume
|
||||
|
||||
The plugin SHALL expose an SSE endpoint that supports `Last-Event-Id` resume per design doc §11.6.
|
||||
|
||||
#### Scenario: client reconnects with Last-Event-Id
|
||||
|
||||
- WHEN a client reconnects to `/example/sse` with `Last-Event-Id: 42`
|
||||
- THEN the server replays events with id > 42
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"SSE replays events after Last-Event-Id"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Owned saga with compensations + 2PC participation
|
||||
|
||||
The plugin SHALL own a 3-step saga AND participate in a host-managed 2PC transaction. Both happy and failure paths are exercised.
|
||||
|
||||
#### Scenario: saga happy path commits every step
|
||||
|
||||
- WHEN the saga runs to completion
|
||||
- THEN every step's effect is observable AND no compensations run
|
||||
|
||||
#### Scenario: saga failure triggers compensations
|
||||
|
||||
- WHEN step 2 throws
|
||||
- THEN step 1's compensation runs AND no step 3 effect is observable
|
||||
|
||||
#### Scenario: 2PC prepare→commit cycle
|
||||
|
||||
- WHEN the host issues prepare to the plugin AND every other participant votes yes
|
||||
- THEN the host issues commit AND the plugin observes commit before responding
|
||||
|
||||
#### Scenario: 2PC abort cycle
|
||||
|
||||
- WHEN any participant votes no
|
||||
- THEN the plugin observes abort AND no committed state is observable
|
||||
|
||||
**Verified by:** `tests/integration/saga-2pc.integration.test.ts::"saga happy path commits every step"` AND `...::"saga failure triggers compensations"` AND `...::"2PC prepare→commit cycle"` AND `...::"2PC abort cycle"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Background jobs — cron, per-tenant cron, queued
|
||||
|
||||
The plugin SHALL register a global cron job, a per-tenant cron job, and a queued job. The integration test advances `FakeClock` to fire them.
|
||||
|
||||
#### Scenario: global cron fires on schedule
|
||||
|
||||
- WHEN `FakeClock` advances past the cron expression
|
||||
- THEN `reconcile-invoices` runs exactly once for that tick
|
||||
|
||||
#### Scenario: per-tenant cron fans out
|
||||
|
||||
- WHEN three tenants exist AND `FakeClock` advances past the schedule
|
||||
- THEN `send-monthly-statements` runs three times — once per tenant — each with a tenant-scoped context
|
||||
|
||||
#### Scenario: queued job retries on failure up to maxAttempts
|
||||
|
||||
- WHEN `send-receipt` throws on attempt 1
|
||||
- THEN attempt 2 runs after backoff AND after `maxAttempts: 5` the job is parked in DLQ
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"cron fires on schedule"` AND `...::"per-tenant cron fans out"` AND `...::"queued job retries up to maxAttempts then DLQ"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Event publish + subscribe
|
||||
|
||||
The plugin SHALL publish `example.invoice.created` AND subscribe to `users.user.deleted`.
|
||||
|
||||
#### Scenario: publishing increments the event bus
|
||||
|
||||
- WHEN an invoice is created
|
||||
- THEN exactly one `example.invoice.created` event is observed on the bus
|
||||
|
||||
#### Scenario: subscriber handler runs on incoming event
|
||||
|
||||
- WHEN `users.user.deleted` is published by a sibling plugin
|
||||
- THEN the example's handler runs exactly once for that delivery
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"publish increments event bus"` AND `tests/integration/full-flow.integration.test.ts::"subscriber handler runs on incoming event"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Custom error codes mapped to HTTP statuses
|
||||
|
||||
The plugin SHALL declare `PLUGIN.EXAMPLE.*` codes in `error-codes.ts` AND map them in `errors.statusMap` in the manifest. Each code MUST be thrown by at least one production path AND surface with the manifested HTTP status.
|
||||
|
||||
#### Scenario: SUBSCRIPTION_INACTIVE returns 409
|
||||
|
||||
- WHEN a request triggers `PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE`
|
||||
- THEN response status is 409 AND error envelope includes that code
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"custom error code surfaces with manifest-mapped status"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Custom metrics declared in manifest emit values
|
||||
|
||||
The plugin SHALL declare `example.invoices_finalised_total`, `example.stripe.call_duration_ms`, and `example.outstanding_invoice_count`. Each MUST emit values during the integration test, asserted against a real in-memory `MeterProvider`.
|
||||
|
||||
#### Scenario: counter emits on invoice finalise
|
||||
|
||||
- WHEN an invoice is finalised
|
||||
- THEN `example.invoices_finalised_total` increments by 1
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"custom metrics emit values against MeterProvider"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Multiple secrets read at runtime
|
||||
|
||||
The plugin SHALL declare ≥3 secrets in `secrets[]` AND read each at runtime via `ctx.secrets`.
|
||||
|
||||
#### Scenario: every declared secret reads successfully
|
||||
|
||||
- WHEN the plugin starts
|
||||
- THEN each declared secret returns its configured test value AND no `SECRETS_*_NOT_ALLOWED` error is raised
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.integration.test.ts::"every declared secret reads successfully"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Schema migrations across two versions with auto-rollback
|
||||
|
||||
The plugin SHALL ship migrations at `schemaVersion: 1` and `schemaVersion: 2`. The integration test forces the v2 migration to fail and asserts the host auto-rolls back to v1.
|
||||
|
||||
#### Scenario: v1 → v2 upgrade succeeds on happy path
|
||||
|
||||
- WHEN a host with v1 reloads to v2 with the v2 migration green
|
||||
- THEN the plugin reaches `started` with v2 schema active
|
||||
|
||||
#### Scenario: v2 migration failure auto-rolls back
|
||||
|
||||
- WHEN the v2 migration throws
|
||||
- THEN the host transitions the plugin to `rolled_back` AND the prior known-good v1 module serves traffic AND a structured audit log entry is emitted
|
||||
|
||||
**Verified by:** `tests/integration/migration-rollback.integration.test.ts::"v1 → v2 upgrade succeeds on happy path"` AND `tests/integration/migration-rollback.integration.test.ts::"v2 migration failure auto-rolls back to v1"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Intentional schema-violation route exercises response validation
|
||||
|
||||
The plugin SHALL expose `/_/test/fail-response?failResponse=1` returning a body that violates the declared response schema. The integration test runs it in `enforce` (expects 500) and `warn` (expects body + log + counter increment).
|
||||
|
||||
#### Scenario: enforce mode returns 500
|
||||
|
||||
- WHEN `host.responseValidation = "enforce"` AND the route is hit with `?failResponse=1`
|
||||
- THEN response is 500
|
||||
|
||||
#### Scenario: warn mode returns body but logs and counts
|
||||
|
||||
- WHEN `host.responseValidation = "warn"` AND the route is hit with `?failResponse=1`
|
||||
- THEN response carries the (invalid) body AND `api.response_validation.failures_total` increments by 1
|
||||
|
||||
**Verified by:** `tests/integration/response-validation.integration.test.ts::"enforce mode returns 500"` AND `tests/integration/response-validation.integration.test.ts::"warn mode returns body but increments counter"`
|
||||
Reference in New Issue
Block a user