12 KiB
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 integrationit(...).
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
startedAND a request to/example/healthreturns 200
Scenario: isolated-tier boot
- WHEN the host installs the plugin with
isolation: "isolated" - THEN the worker thread starts AND a request to
/example/healthreturns 200
Scenario: sandboxed-vm boot
- WHEN the host installs the plugin with
isolation: "sandboxed"andhost.defaultSandbox = "vm" - THEN the plugin runs inside
api-sandbox-vmAND a request to/example/healthreturns 200
Scenario: sandboxed-wasm boot
- WHEN the host installs the plugin with
isolation: "sandboxed"andhost.defaultSandbox = "wasm" - THEN the plugin runs inside
api-sandbox-wasmAND a request to/example/healthreturns 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.sessionresolution required
Scenario: private RBAC-gated route enforces scope
- WHEN a request hits
/example/invoiceswith a principal lackingexample:read - THEN response is 403 with
code === "RBAC_MISSING_SCOPE"
Scenario: validated body rejects malformed payload
- WHEN a request hits
/example/invoiceswithbody.amountCentsmissing - 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-Keyhit/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-Afterset
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-exampleand the sibling plugin - THEN the result contains both AND no field is
nulldue to federation failure
Scenario: GraphQL subscription delivers events
- WHEN a subscription to
exampleInvoiceCreatedis 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/wsand the plugin publishesexample.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/ssewithLast-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
FakeClockadvances past the cron expression - THEN
reconcile-invoicesruns exactly once for that tick
Scenario: per-tenant cron fans out
- WHEN three tenants exist AND
FakeClockadvances past the schedule - THEN
send-monthly-statementsruns three times — once per tenant — each with a tenant-scoped context
Scenario: queued job retries on failure up to maxAttempts
- WHEN
send-receiptthrows on attempt 1 - THEN attempt 2 runs after backoff AND after
maxAttempts: 5the 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.createdevent is observed on the bus
Scenario: subscriber handler runs on incoming event
- WHEN
users.user.deletedis 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_totalincrements 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_ALLOWEDerror 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
startedwith v2 schema active
Scenario: v2 migration failure auto-rolls back
- WHEN the v2 migration throws
- THEN the host transitions the plugin to
rolled_backAND 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_totalincrements 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"