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

0
openspec/specs/.gitkeep Normal file
View File

View File

@@ -0,0 +1,362 @@
# Capability Spec Delta: analytics-browser (analytics-browser)
**Package**: `@suaveplan/analytics-browser`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Error code registry
The package SHALL expose `ANALYTICS_BROWSER_CODES` defined via `@suaveplan/error`'s `defineCodes` with the eight canonical reasons (`INVALID_OPTIONS`, `TRANSPORT_TIMEOUT`, `TRANSPORT_FAILED`, `QUEUE_FULL`, `STORAGE_UNAVAILABLE`, `SHUTDOWN`, `INVALID_EVENT`, `BEACON_REJECTED`) and a typed `AnalyticsBrowserError` extending `SuaveplanError<AnalyticsBrowserCode>`.
#### Scenario: every code is namespaced under `ANALYTICS_BROWSER.`
- **WHEN** any value of `ANALYTICS_BROWSER_CODES` is read
- **THEN** the value matches the prefix `ANALYTICS_BROWSER.`
#### Scenario: typed error carries code + context
- **WHEN** `new AnalyticsBrowserError(code, message, { context })` is constructed
- **THEN** the instance is `instanceof AnalyticsBrowserError`, `error.code === code`, and `error.context` exposes the supplied bag
**Verified by:** `src/error-codes/error-codes.test.ts::"namespaces every code under ANALYTICS_BROWSER."` AND `src/error-codes/error-codes.test.ts::"carries the supplied code, message, cause, and context"`
---
### Requirement: Cryptographically random ID generator
The package SHALL expose an internal `generateId(): string` returning 32-character hex strings drawn from `crypto.getRandomValues` and used for both anonymous and session IDs. Two consecutive calls MUST produce different values.
#### Scenario: returns 32 hex chars
- **WHEN** `generateId()` is invoked
- **THEN** the result matches `/^[0-9a-f]{32}$/`
#### Scenario: distinct across calls
- **WHEN** `generateId()` is invoked twice
- **THEN** the two results are not equal
**Verified by:** `src/internal/id/id.test.ts::"emits 32 hex chars"` AND `src/internal/id/id.test.ts::"never repeats across consecutive calls"`
---
### Requirement: Zod-validated `AnalyticsEvent` schema
The package SHALL expose `AnalyticsEventSchema` (a Zod schema) and `validateAnalyticsEvent(input): AnalyticsEvent` that throws `AnalyticsBrowserError(INVALID_EVENT)` when the input fails parsing.
#### Scenario: accepts a well-formed event
- **WHEN** `validateAnalyticsEvent({ id, name, properties, timestamp, sessionId, anonymousId })` is called with valid values
- **THEN** the returned value equals the input
#### Scenario: rejects missing required fields
- **WHEN** `validateAnalyticsEvent({ name: "x" })` is called
- **THEN** an `AnalyticsBrowserError` with `code === ANALYTICS_BROWSER.INVALID_EVENT` is thrown
**Verified by:** `src/schema/analytics-event-schema.test.ts::"accepts a well-formed event"` AND `src/schema/analytics-event-schema.test.ts::"rejects events missing required fields"`
---
### Requirement: Consent gate
The package SHALL expose a `ConsentPolicy` interface, an `AllowAllPolicy` default, and a `DoNotTrackPolicy` honouring `navigator.doNotTrack === "1"`. The tracker MUST consult the policy before enqueueing any event and emit a `drop` event with reason `consent` when blocked.
#### Scenario: DoNotTrack blocks events
- **WHEN** `navigator.doNotTrack === "1"` and a `DoNotTrackPolicy` is consulted with any event
- **THEN** `isAllowed(event)` returns `false`
#### Scenario: AllowAllPolicy permits every event
- **WHEN** `AllowAllPolicy.isAllowed(event)` is called with any event
- **THEN** the return value is `true`
**Verified by:** `src/consent/consent.test.ts::"DoNotTrackPolicy blocks when navigator.doNotTrack === '1'"` AND `src/consent/consent.test.ts::"AllowAllPolicy permits every event"`
---
### Requirement: PII scrubber
The package SHALL expose `scrubPII(event, rules)` applying `redact` (replace value with `"[REDACTED]"`), `hash` (replace value with hex SHA-256-like fingerprint via Web Crypto), and `drop` (omit field) strategies per `ScrubRule[]`. Dotted field paths (`user.email`) SHALL resolve into nested `properties` objects.
#### Scenario: redact replaces value with [REDACTED]
- **WHEN** `scrubPII(event, [{ field: "email", strategy: "redact" }])` is called
- **THEN** `result.properties.email === "[REDACTED]"`
#### Scenario: drop removes the field entirely
- **WHEN** `scrubPII(event, [{ field: "ssn", strategy: "drop" }])` is called
- **THEN** `"ssn" in result.properties` is `false`
#### Scenario: hash replaces value with a deterministic fingerprint
- **WHEN** `scrubPII(event, [{ field: "phone", strategy: "hash" }])` is called twice on the same input
- **THEN** both results expose the same non-empty fingerprint string distinct from the original value
**Verified by:** `src/pii/pii.test.ts::"redact replaces value with [REDACTED]"` AND `src/pii/pii.test.ts::"drop removes the field entirely"` AND `src/pii/pii.test.ts::"hash produces a deterministic fingerprint"`
---
### Requirement: Head sampler with per-event overrides
The package SHALL expose `HeadSampler` accepting a base sample rate in `[0, 1]` and an optional `overrides` map (`Record<EventName, rate>`). Per-event override rate takes precedence over the base rate.
#### Scenario: rate=0 drops everything
- **WHEN** `new HeadSampler(0).shouldSample(event)` is called
- **THEN** the return value is `false`
#### Scenario: rate=1 keeps everything
- **WHEN** `new HeadSampler(1).shouldSample(event)` is called
- **THEN** the return value is `true`
#### Scenario: override beats base rate
- **WHEN** `new HeadSampler(0, { "page": 1 }).shouldSample({ name: "page", … })` is called
- **THEN** the return value is `true`
**Verified by:** `src/sampling/sampling.test.ts::"HeadSampler rate=0 drops every event"` AND `src/sampling/sampling.test.ts::"HeadSampler rate=1 keeps every event"` AND `src/sampling/sampling.test.ts::"per-event override beats base rate"`
---
### Requirement: Pluggable storage backends
The package SHALL expose a `StorageBackend` interface (`enqueue(events)`, `dequeueAll()`, `clear()`, `getDepth()`) with two bundled implementations: `MemoryStorageBackend` and `WebStorageBackend` (`Storage`-backed, JSON-encoded, `maxBytes` watermark).
#### Scenario: memory backend round-trips events
- **WHEN** events are enqueued into `MemoryStorageBackend` and then `dequeueAll()` is called
- **THEN** the returned array equals the enqueued events and `getDepth()` returns `0`
#### Scenario: web storage backend persists across instances
- **WHEN** events are enqueued into `WebStorageBackend({ storage })` and a second instance is created with the same `storage`
- **THEN** the second instance's `dequeueAll()` returns the enqueued events
#### Scenario: web storage backend rejects overflow
- **WHEN** events exceeding the configured `maxBytes` are enqueued
- **THEN** `AnalyticsBrowserError` with code `ANALYTICS_BROWSER.QUEUE_FULL` is thrown
**Verified by:** `src/storage/storage.test.ts::"MemoryStorageBackend round-trips events"` AND `src/storage/storage.test.ts::"WebStorageBackend persists across instances"` AND `src/storage/storage.test.ts::"WebStorageBackend rejects events exceeding maxBytes"`
---
### Requirement: SessionManager lifecycle
The package SHALL expose `SessionManager` tracking session id, page-view count, duration, referrer, inactivity timeout, and exposing `getSessionId()`, `recordPageView(url, props)`, `touch()`, `getDuration()`, `getReferrer()`, `getPageViews()`, `isActive()`, `end()`, `renew()`, `getInfo()`.
#### Scenario: renew generates a fresh session id
- **WHEN** `renew()` is called on a `SessionManager`
- **THEN** the post-renew `getSessionId()` differs from the pre-renew value and `getPageViews() === 0`
#### Scenario: inactivity expires the session
- **WHEN** `isActive()` is called after more than `timeoutMs` of inactivity
- **THEN** the return value is `false`
**Verified by:** `src/session/session.test.ts::"renew generates a fresh session id and zeroes page views"` AND `src/session/session.test.ts::"inactivity beyond timeoutMs deactivates the session"`
---
### Requirement: `BatchTransport` with AbortController + exponential backoff
The package SHALL expose `BatchTransport` posting `{ events }` JSON to a configured endpoint via injected `fetchFn` (defaulting to `globalThis.fetch`). Every request MUST be wrapped in an `AbortController` armed with the configured `timeoutMs` (default 10_000) and SHALL throw `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` on abort. `4xx` responses MUST short-circuit (no retry). `5xx` / network errors MUST retry with exponential backoff `retryDelay * 2 ** attempt` up to `maxRetries`.
#### Scenario: 200 response returns success
- **WHEN** the injected fetch returns a `Response` with `status === 200` and a batch is sent
- **THEN** `send()` resolves to `true`
#### Scenario: 4xx short-circuits without retry
- **WHEN** the injected fetch returns `status === 400` once
- **THEN** `send()` resolves to `false` and the fetch is called exactly once
#### Scenario: timeout aborts the fetch and throws `TRANSPORT_TIMEOUT`
- **WHEN** the injected fetch never resolves and `timeoutMs` elapses
- **THEN** `send()` rejects with an `AnalyticsBrowserError` of code `ANALYTICS_BROWSER.TRANSPORT_TIMEOUT`
#### Scenario: 5xx retries with backoff
- **WHEN** the injected fetch returns `status === 503` `maxRetries` times then `200`
- **THEN** `send()` resolves to `true` and the fetch was called `maxRetries + 1` times
**Verified by:** `src/transport/batch.test.ts::"200 response resolves to success"` AND `src/transport/batch.test.ts::"4xx response short-circuits with no retry"` AND `src/transport/batch.test.ts::"timeoutMs aborts the fetch and throws TRANSPORT_TIMEOUT"` AND `src/transport/batch.test.ts::"5xx retries with exponential backoff"`
---
### Requirement: `BeaconTransport` for unload flushes
The package SHALL expose `BeaconTransport` delivering a batch via injected `sendBeaconFn` (defaulting to `navigator.sendBeacon.bind(navigator)`). On rejection (`sendBeacon` returns `false`), the transport MUST fall back to `fetch` with `keepalive: true` when `fallbackToFetch` is enabled, otherwise SHALL throw `AnalyticsBrowserError(BEACON_REJECTED)`.
#### Scenario: sendBeacon true resolves to success
- **WHEN** injected `sendBeaconFn` returns `true`
- **THEN** `send()` resolves to `true`
#### Scenario: sendBeacon false with fallback uses fetch
- **WHEN** injected `sendBeaconFn` returns `false` and `fallbackToFetch === true`
- **THEN** the injected `fetchFn` is invoked with `keepalive: true`
#### Scenario: sendBeacon false without fallback throws
- **WHEN** injected `sendBeaconFn` returns `false` and `fallbackToFetch === false`
- **THEN** `send()` rejects with `AnalyticsBrowserError(BEACON_REJECTED)`
**Verified by:** `src/transport/beacon.test.ts::"sendBeacon true resolves to success"` AND `src/transport/beacon.test.ts::"sendBeacon false with fallback uses fetch keepalive"` AND `src/transport/beacon.test.ts::"sendBeacon false without fallback throws BEACON_REJECTED"`
---
### Requirement: `AnalyticsTracker` event pipeline
The package SHALL expose `AnalyticsTracker` accepting a `Transport` and partial `TrackerOptions` (`endpoint`, `batchSize`, `flushInterval`, `consentPolicy`, `sampler`, `scrubRules`, `storage`, `clock`, `timers`, `idGenerator`). Calling `track(name, props)` MUST: (1) consult `ConsentPolicy`; (2) consult `Sampler`; (3) apply PII scrubber if rules are set; (4) push to in-memory queue and storage backend; (5) emit `track` on the bus; (6) increment the `events_tracked_total` counter; (7) trigger `flush()` when queue ≥ `batchSize`. `track()` MUST be a no-op after `shutdown()`.
#### Scenario: track enqueues a well-formed event
- **WHEN** `tracker.track("clicked", { x: 1 })` is called on a fresh tracker
- **THEN** `tracker.getQueueSize() === 1` and the bus emitted `track` with the event payload
#### Scenario: consent block drops the event
- **WHEN** the tracker is configured with a `ConsentPolicy` whose `isAllowed` returns `false` and `track()` is called
- **THEN** `getQueueSize() === 0` and the bus emitted `drop` with `reason: "consent"`
#### Scenario: sampling drop emits a `drop` event
- **WHEN** the tracker is configured with `new HeadSampler(0)` and `track()` is called
- **THEN** `getQueueSize() === 0` and the bus emitted `drop` with `reason: "sampling"`
#### Scenario: PII scrubber redacts before enqueue
- **WHEN** the tracker is configured with `[{ field: "email", strategy: "redact" }]` and `track("event", { email: "x@y" })` is called
- **THEN** the enqueued event's `properties.email === "[REDACTED]"`
#### Scenario: batch-size threshold triggers flush
- **WHEN** `batchSize` events are tracked
- **THEN** the configured transport's `send()` is invoked exactly once
#### Scenario: shutdown drops new tracks and emits `shutdown`
- **WHEN** `await tracker.shutdown()` resolves and `track("x")` is then called
- **THEN** `getQueueSize() === 0` and the bus emitted `shutdown` exactly once
**Verified by:** `src/tracker/tracker.test.ts::"track enqueues a well-formed event"` AND `src/tracker/tracker.test.ts::"consent block drops the event"` AND `src/tracker/tracker.test.ts::"sampling drop emits drop with reason sampling"` AND `src/tracker/tracker.test.ts::"PII scrubber redacts before enqueue"` AND `src/tracker/tracker.test.ts::"batch-size threshold triggers flush"` AND `src/tracker/tracker.test.ts::"shutdown ignores subsequent track calls"`
---
### Requirement: Durable storage round-trip
The tracker SHALL persist accepted events into the injected `StorageBackend` and SHALL clear the backend after a successful flush. A new tracker constructed against the same backend MUST restore previously persisted events into its in-memory queue.
#### Scenario: storage backend receives every accepted event
- **WHEN** `track("a")` and `track("b")` are called and the configured backend is a `MemoryStorageBackend`
- **THEN** `backend.getDepth() === 2`
#### Scenario: successful flush clears the backend
- **WHEN** flush succeeds against the configured transport
- **THEN** `backend.getDepth() === 0` post-flush
#### Scenario: new tracker restores from storage
- **WHEN** a fresh tracker is constructed with a backend containing previously persisted events
- **THEN** `getQueueSize()` equals the previously persisted depth after `restore()` resolves
**Verified by:** `src/tracker/tracker.test.ts::"storage backend receives every accepted event"` AND `src/tracker/tracker.test.ts::"successful flush clears the storage backend"` AND `src/tracker/tracker.test.ts::"restore replays persisted events into the in-memory queue"`
---
### Requirement: Identify + global properties + session pinning
The tracker SHALL expose `identify(userId, traits?)`, `setProperties(props)`, `setSessionId(id)`, `getAnonymousId()`, `getUserId()`, `getSessionId()`. `identify()` MUST emit `identify` on the bus. Subsequent `track()` calls MUST attach the current `userId`, `sessionId`, `anonymousId`, and merge global properties beneath per-call properties.
#### Scenario: identify pins userId on subsequent events
- **WHEN** `tracker.identify("u1")` then `tracker.track("a")` is called
- **THEN** the enqueued event's `userId === "u1"`
#### Scenario: setProperties merges into every event
- **WHEN** `tracker.setProperties({ app: "shell" })` then `tracker.track("a", { x: 1 })` is called
- **THEN** the enqueued event's properties equal `{ app: "shell", x: 1 }`
**Verified by:** `src/tracker/tracker.test.ts::"identify pins userId on subsequent events"` AND `src/tracker/tracker.test.ts::"setProperties merges into every event"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL expose `eventsTrackedCounter`, `eventsFlushedCounter`, `eventsDroppedCounter`, `batchSizeHistogram`, `flushLatencyHistogram` via `@opentelemetry/api` and SHALL record on every tracked / flushed / dropped event. The flush counter MUST tag `status` (`success` / `failure`) and the drop counter MUST tag `reason` (`consent` / `sampling` / `queue_full` / `shutdown` / `invalid`).
#### Scenario: tracked event increments counter
- **WHEN** `tracker.track("x")` is called and metrics are flushed
- **THEN** the `analytics_browser.events_tracked_total` counter exposes a non-zero data point
#### Scenario: flush failure records `status=failure`
- **WHEN** the configured transport returns `false` and a flush executes
- **THEN** the `analytics_browser.events_flushed_total` counter exposes a data point with `status=failure`
#### Scenario: consent drop records `reason=consent`
- **WHEN** the consent gate blocks an event
- **THEN** the `analytics_browser.events_dropped_total` counter exposes a data point with `reason=consent`
**Verified by:** `src/telemetry/telemetry.test.ts::"records tracked events"` AND `src/telemetry/telemetry.test.ts::"records flush status"` AND `src/telemetry/telemetry.test.ts::"records drop reason"`
---
### Requirement: AbortController + timeout never leaks
The package SHALL clean up every `AbortController` and `setTimeout` armed by `BatchTransport.send()` regardless of resolution path. A successful or failed send MUST NOT leave a dangling timer reference detectable through a real `TimersLike` adapter.
#### Scenario: success clears the timeout
- **WHEN** the injected fetch returns `200` and the test `TimersLike` records timer counts
- **THEN** the cleared-timeout count equals the armed-timeout count
#### Scenario: timeout abort clears the timeout
- **WHEN** the fetch never resolves and the timeout fires
- **THEN** the cleared-timeout count equals the armed-timeout count
**Verified by:** `src/transport/batch.test.ts::"successful send clears the armed timeout"` AND `src/transport/batch.test.ts::"timeout abort clears the armed timeout"`
---
### Requirement: Tier purity — no `node:` imports
The package SHALL contain zero `from "node:*"` imports in `src/` to preserve browser-tier portability. A repository-level grep MUST return zero matches inside the package.
#### Scenario: src contains no node: imports
- **WHEN** `grep -rn "from \"node:" packages/observability/monitor/analytics-browser/src/` is executed
- **THEN** the grep produces no output
**Verified by:** `tests/integration.test.ts::"src/ contains no node: imports"`
---
### Requirement: Integration — end-to-end track → batch → http round-trip
The package SHALL be integration-tested with a real `EventBus`, a real `BatchTransport`, and an in-process fetch handler. The handler MUST observe a `POST` body containing the tracked events, and the bus MUST emit `track` then `flush` events in order.
#### Scenario: tracked event arrives at the in-process endpoint
- **WHEN** `tracker.track("e")` is called with `batchSize: 1` against a real `BatchTransport` whose `fetchFn` is an in-process handler
- **THEN** the handler observes a single `POST` carrying one event with `name === "e"` and the bus emitted `track` followed by `flush` with `success: true`
**Verified by:** `tests/integration.test.ts::"tracker → batch transport → in-process endpoint round-trip"`

View File

@@ -0,0 +1,143 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-2pc
**Package**: `@suaveplan/api-2pc`
**Status**: SHIPPED (0.1.0)
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Two-phase commit driven against Postgres `PREPARE TRANSACTION`
The package SHALL expose `run2pc({ participants, timeoutMs? }, ctx)`. Phase 1 sends `prepare` to every participant; each participant runs its work inside a transaction and issues `PREPARE TRANSACTION '<participantTxId>'`, returning `vote_commit` or `vote_abort`. Phase 2: if all participants vote commit, the coordinator issues `COMMIT PREPARED` everywhere; if any participant votes abort OR the prepare-phase deadline expires, the coordinator issues `ROLLBACK PREPARED` everywhere.
#### Scenario: unanimous commit completes successfully
- **WHEN** every participant votes commit
- **THEN** the coordinator issues `COMMIT PREPARED` on every participant AND the transaction ends in `committed`
#### Scenario: any vote-abort rolls back everywhere
- **WHEN** at least one participant votes abort
- **THEN** the coordinator issues `ROLLBACK PREPARED` on every participant that returned `vote_commit` AND emits `TWOPC_VOTE_ABORT`
**Verified by:** `src/coordinator/coordinator.test.ts::"unanimous commit issues COMMIT PREPARED on every participant"` AND `src/coordinator/coordinator.test.ts::"any vote-abort rolls back everywhere with TWOPC_VOTE_ABORT"`
---
### Requirement: SQLite and SurrealDB cannot participate as writers
The participant registry SHALL refuse registration of a writing participant whose bound DB engine does not advertise the `db.preparable` capability. SQLite and SurrealDB plugins MAY register as read-only voters that contribute a `vote_commit`/`vote_abort` based on their own consistency check but MUST NOT be sent prepare/commit SQL. Attempting to bind one as a writer fails at install with `TWOPC_PARTICIPANT_NOT_PREPARABLE`.
#### Scenario: SQLite plugin is rejected as a writer
- **WHEN** a plugin bound to SQLite declares `twoPhaseCommit.participates: true` AND is registered as a write participant
- **THEN** install fails with `TWOPC_PARTICIPANT_NOT_PREPARABLE`
#### Scenario: SurrealDB plugin participates as a read-only voter
- **WHEN** a SurrealDB-bound plugin is registered with `voterMode: "read-only"`
- **THEN** registration succeeds AND the coordinator collects its vote in phase 1 but never sends commit/rollback SQL
#### Scenario: read-only voter that attempts a write is refused
- **WHEN** a read-only voter's prepare callback returns a write-intent payload
- **THEN** the coordinator fails with `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`
**Verified by:** `src/registry/participant-registry.test.ts::"rejects SQLite writer with TWOPC_PARTICIPANT_NOT_PREPARABLE"` AND `src/registry/participant-registry.test.ts::"accepts SurrealDB plugin as read-only voter"` AND `src/registry/participant-registry.test.ts::"refuses write-intent from read-only voter"`
---
### Requirement: Timeout default and hard ceiling
The default `timeoutMs` SHALL be 5000ms. The hard ceiling SHALL be 30000ms — calls supplying `timeoutMs > 30000` MUST be refused at call time with `TWOPC_TIMEOUT_CEILING_EXCEEDED` (NOT silently clamped — the operational warning in §9.2 is that long prepares hold row-level locks; the coordinator surfaces the misuse, not hides it). Exceeding the runtime deadline during phase 1 triggers rollback with `TWOPC_TIMEOUT`.
#### Scenario: default timeout applies when none is supplied
- **WHEN** `run2pc` is called without `timeoutMs`
- **THEN** the effective deadline is 5000ms
#### Scenario: timeoutMs above ceiling is refused
- **WHEN** `run2pc({ ..., timeoutMs: 30001 })` is called
- **THEN** the call rejects with `TWOPC_TIMEOUT_CEILING_EXCEEDED` BEFORE any participant is contacted
#### Scenario: deadline expiry during prepare triggers rollback
- **WHEN** any participant has not returned a vote before the deadline
- **THEN** the coordinator issues `ROLLBACK PREPARED` to every prepared participant AND emits `TWOPC_TIMEOUT`
**Verified by:** `src/limits/timeout-ceiling.test.ts::"default timeoutMs is 5000"` AND `src/limits/timeout-ceiling.test.ts::"timeoutMs above 30000 rejects with TWOPC_TIMEOUT_CEILING_EXCEEDED"` AND `src/coordinator/coordinator.test.ts::"deadline expiry during prepare triggers rollback with TWOPC_TIMEOUT"`
---
### Requirement: Outstanding-prepared cap
The coordinator SHALL enforce a default cap of 256 outstanding prepared transactions across all in-flight calls. This default is the spec'd implementation choice for design doc §14's open question on coordinator throttling — it is operator-tunable via host config but the default ships at 256. Exceeding the cap SHALL fail new `run2pc` calls with `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` until in-flight transactions complete.
#### Scenario: 257th concurrent transaction is refused
- **WHEN** 256 transactions are in phase 1 or phase 2 AND a 257th call begins
- **THEN** the 257th call rejects with `TWOPC_OUTSTANDING_LIMIT_EXCEEDED`
#### Scenario: cap recovers as transactions complete
- **WHEN** an in-flight transaction completes AND a new call arrives
- **THEN** the new call is admitted
**Verified by:** `src/limits/outstanding-cap.test.ts::"257th concurrent transaction is refused"` AND `src/limits/outstanding-cap.test.ts::"slot recovers as transactions complete"`
---
### Requirement: Persisted state and boot-time orphan resolution
Every coordinator transition SHALL be persisted to `suaveplan_2pc_transactions` (schema owned by `api-host`). On coordinator boot the recovery runner MUST query every participant's `pg_prepared_xacts` for transactions tagged with the host's instance ID. For each orphan: if the ledger row records `all-voted-commit`, issue `COMMIT PREPARED`; otherwise issue `ROLLBACK PREPARED`. Transactions without a corresponding ledger row MUST be rolled back (safe default — no commit without explicit log).
#### Scenario: orphan with "all-voted-commit" ledger row is committed
- **WHEN** the coordinator boots AND finds a prepared transaction whose ledger row is `all-voted-commit`
- **THEN** `COMMIT PREPARED` is issued AND the row is moved to `committed`
#### Scenario: orphan with no ledger row is rolled back
- **WHEN** the coordinator boots AND finds a prepared transaction with no matching ledger row
- **THEN** `ROLLBACK PREPARED` is issued
**Verified by:** `src/recovery/recovery.test.ts::"orphan with all-voted-commit ledger row is committed"` AND `src/recovery/recovery.test.ts::"orphan with no ledger row is rolled back"`
---
### Requirement: OTel instruments expose prepare and commit durations
The coordinator SHALL emit, against an injected OTel meter and tracer:
- `2pc.transaction` span per transaction lifetime (attributes: `tx.id`, `outcome`, `participant_count`).
- `2pc.prepare`, `2pc.commit`, `2pc.rollback` spans per participant phase.
- `2pc.transactions_total` counter labelled `{ outcome }`.
- `2pc.prepare_duration_ms` histogram labelled `{ outcome }`.
- `2pc.commit_duration_ms` histogram labelled `{ outcome }`.
- `2pc.outstanding_prepared` observable gauge.
Operators rely on `prepare_duration_ms` to spot lock-holding times before they cause throughput regressions.
#### Scenario: successful commit emits both prepare and commit histograms
- **WHEN** a 2PC run completes successfully
- **THEN** an in-memory `MeterProvider` observes one observation in `2pc.prepare_duration_ms` AND one in `2pc.commit_duration_ms` AND the outstanding gauge briefly rises then falls
**Verified by:** `src/instruments/instruments.test.ts::"emits prepare_duration_ms and commit_duration_ms on successful commit"`
---
### Requirement: Conformance suite for participants
The package SHALL publish `run2pcParticipantConformance(participantFactory)` on the `./testing` subpath, pinning prepare-callback signature, vote semantics, idempotent commit/rollback contract, and the requirement that participants advertise `db.preparable`.
#### Scenario: reference participant passes the suite
- **WHEN** the suite runs against the in-package reference Postgres participant
- **THEN** every invariant passes
**Verified by:** `src/coordinator/coordinator.spec-conformance.test.ts::"reference Postgres participant passes run2pcParticipantConformance"`

View File

@@ -0,0 +1,99 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-audit-file
**Package**: `@suaveplan/api-audit-file`
**Status**: ACCEPTED
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: JSON-per-line file writes with HMAC signature
The package SHALL write each audit record as a single JSON line to the configured file `path` with an HMAC-SHA-256 signature computed over the canonical record bytes.
#### Scenario: 1000 records write as 1000 JSON lines
- WHEN 1000 valid records are written
- THEN the file contains 1000 lines, each parses as JSON AND each line's HMAC verifies
**Verified by:** `src/sink/sink.test.ts::"writes 1000 records as 1000 verified JSON lines"`
---
### Requirement: Size-based rotation
The package SHALL rotate the file when its size reaches `rotation.maxSizeBytes` (default 100 MB). The rotated file is renamed to `<path>.<utc-ISO>.log` and a new empty file is opened. Subsequent writes go to the new file.
#### Scenario: write past maxSizeBytes triggers rotation
- WHEN `maxSizeBytes` is 1024 AND 2048 bytes of records are written
- THEN a rotated file with an ISO-timestamp suffix exists AND subsequent writes go to a fresh file
**Verified by:** `src/rotation/rotation.test.ts::"size rotation produces timestamp-suffixed file"`
---
### Requirement: Time-based rotation
The package SHALL rotate when the current file's age (against the supplied `Clock`) reaches `rotation.maxAgeMs` (default 24h). Time rotation uses `FakeClock` in tests for determinism.
#### Scenario: clock advance past maxAgeMs triggers rotation
- WHEN `maxAgeMs` is 60_000ms AND the clock advances by 61_000ms
- THEN the next write rotates first AND lands in the new file
**Verified by:** `src/rotation/rotation.test.ts::"time rotation triggers on clock advance"`
---
### Requirement: Retention enforces maxFiles
The package SHALL retain at most `rotation.maxFiles` (default 30) rotated files. Older files are deleted with `api.audit.rotation_evicted_total{sink="file"}` increment.
#### Scenario: N+1 rotated files evicts the oldest
- WHEN `maxFiles` is 3 AND a 4th rotation occurs
- THEN the oldest rotated file is deleted AND `api.audit.rotation_evicted_total` increments by 1
**Verified by:** `src/retention/retention.test.ts::"evicts oldest beyond maxFiles and increments counter"`
---
### Requirement: Path-not-writeable surfaces at boot
The package SHALL fail fast at sink construction if `path`'s directory is not writeable, raising `AUDIT_FILE_PATH_NOT_WRITEABLE`.
#### Scenario: unwriteable path raises at boot
- WHEN `path` resolves to a directory the process cannot write
- THEN `createFileAuditSink` throws `AUDIT_FILE_PATH_NOT_WRITEABLE`
**Verified by:** `src/sink/sink.test.ts::"unwriteable path raises AUDIT_FILE_PATH_NOT_WRITEABLE"`
---
### Requirement: Drops are observable
The package SHALL drop records that exceed `maxQueueMs` and increment `api.audit.dropped_total{sink="file"}`. Drops MUST NEVER be silent.
#### Scenario: drop increments dropped_total
- WHEN the file writer is stalled long enough for the queue to overflow
- THEN the record is dropped AND `api.audit.dropped_total{sink="file"}` increments by 1
**Verified by:** `src/sink/sink.spec-conformance.test.ts::"drop increments dropped_total with sink label file"`
---
### Requirement: HMAC verification detects tampering
The package SHALL produce HMACs such that any byte change in the record invalidates verification with the same key.
#### Scenario: tampering invalidates HMAC
- WHEN one byte in a written line is flipped
- THEN HMAC verification fails
**Verified by:** `src/hmac/hmac.test.ts::"tampering invalidates HMAC"`

View File

@@ -0,0 +1,83 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-audit-stdout (api-audit-stdout)
**Package**: `@suaveplan/api-audit-stdout`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: JSON-per-line writes with HMAC signature
The package SHALL write each audit record as a single JSON line to the supplied writer (default `process.stdout`). Each line MUST include an HMAC-SHA-256 signature computed over the canonical bytes of the record using the configured `hmacKey`.
#### Scenario: written line is JSON with signature
- WHEN `sink.write({ ts: "...", type: "request.error", ... })` is called
- THEN exactly one line is appended to the writer AND that line parses as JSON AND it contains a `_hmac` field whose value verifies against the canonical bytes
**Verified by:** `src/sink/sink.test.ts::"writes JSON-per-line with HMAC signature"`
---
### Requirement: Record shape validated by Zod
The package SHALL validate each record against `AuditRecord` Zod schema BEFORE writing. Invalid records raise `AUDIT_STDOUT_INVALID_RECORD` and MUST NOT be written.
#### Scenario: missing required field rejected
- WHEN a record is missing `request_id`
- THEN `write()` throws `AUDIT_STDOUT_INVALID_RECORD` AND no line is written
#### Scenario: unknown field rejected
- WHEN a record carries an unknown top-level field
- THEN `write()` throws `AUDIT_STDOUT_INVALID_RECORD`
**Verified by:** `src/schema/schema.test.ts::"missing request_id is rejected"` AND `src/schema/schema.test.ts::"unknown field is rejected"`
---
### Requirement: Backpressure drops are observable
The package SHALL block the caller up to `maxQueueMs` (default 100ms) and then drop the record with `api.audit.dropped_total{sink="stdout"}` increment AND a single warning log per minute. Drops MUST NEVER be silent.
#### Scenario: drop increments dropped_total
- WHEN the writer is paused such that the queue exceeds `maxQueueMs`
- THEN the record is dropped AND `api.audit.dropped_total{sink="stdout"}` increments by 1
#### Scenario: warning log throttled to once per minute
- WHEN 100 records are dropped within 30 seconds (with `FakeClock`)
- THEN at most one warning is logged in that minute window
**Verified by:** `src/sink/sink.spec-conformance.test.ts::"drop increments dropped_total"` AND `src/sink/sink.spec-conformance.test.ts::"warning log throttled to once per minute"`
---
### Requirement: Successful writes counted with labels
The package SHALL increment `api.audit.records_total{sink="stdout", type, outcome}` once per successful write.
#### Scenario: successful write increments counter
- WHEN a valid record is written
- THEN `api.audit.records_total{sink="stdout", type, outcome}` increments by exactly 1 with the record's `type` and `outcome` as labels
**Verified by:** `src/sink/sink.spec-conformance.test.ts::"successful write increments records_total with type and outcome labels"`
---
### Requirement: HMAC verification detects tampering
The package SHALL produce HMAC signatures such that any modification to the record's canonical bytes invalidates the signature on verification with the same key.
#### Scenario: tampering invalidates HMAC
- WHEN a written line's `outcome` is flipped from `success` to `failure` after signing
- THEN HMAC verification with the original key returns `false`
**Verified by:** `src/hmac/hmac.test.ts::"tampering invalidates HMAC"`

View File

@@ -0,0 +1,52 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-anonymous (api-auth-anonymous)
**Package**: `@suaveplan/api-auth-anonymous`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: resolveSession always returns null
The package SHALL implement the session-resolver contract such that `resolveSession(request)` returns `null` for every input, synchronously decidable.
#### Scenario: any request resolves to null
- **WHEN** `resolveSession(request)` is called with any request shape (Authorization header, cookie, missing entirely)
- **THEN** the resolver returns `null` AND no exception is thrown AND no network I/O occurs
**Verified by:** `src/factory/create-anonymous-session-resolver.test.ts::"resolveSession returns null for every input"`
---
### Requirement: Direct and worker-proxy surfaces both pass conformance
The adapter SHALL pass `runAuthSessionConformance` under BOTH the direct and worker-proxy surfaces. The conformance suite MUST tolerate a resolver that exclusively returns null (the "no auth configured" case is a valid host topology).
#### Scenario: direct surface conforms
- **WHEN** `runAuthSessionConformance("anonymous-direct", createAnonymousSessionResolver)` runs
- **THEN** every invariant passes
#### Scenario: worker-proxy surface conforms
- **WHEN** `runAuthSessionConformance("anonymous-worker-proxy", () => createAnonymousSessionResolverWorkerProxy(channel.port2))` runs over a real `MessageChannel`
- **THEN** every invariant passes
**Verified by:** `src/factory/create-anonymous-session-resolver.spec-conformance.test.ts::"anonymous-direct conforms to AuthSession contract"` AND `src/proxy/create-anonymous-session-resolver-worker-proxy.spec-conformance.test.ts::"anonymous-worker-proxy conforms to AuthSession contract"`
---
### Requirement: Empty securitySchemes contribution
`securitySchemeContribution()` SHALL return an empty `securitySchemes` block. OpenAPI documents assembled from a host using only this resolver MUST emit no `security` entry on routes.
#### Scenario: contribution is empty
- **WHEN** `securitySchemeContribution()` is called
- **THEN** the returned value is `{ securitySchemes: {} }`
**Verified by:** `src/security-scheme/security-scheme-contribution.test.ts::"contribution exposes no security schemes"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,80 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-bearer-static (api-auth-bearer-static)
**Package**: `@suaveplan/api-auth-bearer-static`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Static token table resolves to Principal
The package SHALL match incoming bearer tokens against a fixed table supplied at construction time. A matching token produces the associated `Principal`; an unknown token produces `null`. Token comparison MUST be constant-time so an attacker cannot probe valid token prefixes via timing.
#### Scenario: known token returns its Principal
- **WHEN** a request carries `Authorization: Bearer T1` AND the table contains `{ token: "T1", principal: P }`
- **THEN** `resolveSession()` returns `P`
#### Scenario: unknown token returns null
- **WHEN** a request carries `Authorization: Bearer NOT_IN_TABLE`
- **THEN** `resolveSession()` returns `null` AND emits `AUTH_BEARER_STATIC_UNKNOWN_TOKEN` on the failure counter
#### Scenario: token match is constant-time
- **WHEN** two unknown tokens of equal length are compared against the table
- **THEN** the comparison loop runs to completion for both (no early exit on first mismatched byte)
**Verified by:** `src/factory/create-bearer-static-resolver.test.ts::"known token returns its Principal"` AND `src/factory/create-bearer-static-resolver.test.ts::"unknown token returns null and increments failure counter"` AND `src/constant-time/token-match.test.ts::"comparison runs to completion regardless of mismatch position"`
---
### Requirement: Optional mutual-TLS gate
A token entry MAY carry `mtlsRequired: true`. When set, the resolver MUST require the request to carry a verified peer certificate (surfaced by the host listener); absence MUST produce `null` and emit `AUTH_BEARER_STATIC_MTLS_REQUIRED`.
#### Scenario: mTLS-gated token rejects requests without peer cert
- **WHEN** an mTLS-gated token is presented but `request.tls.peerCertificate` is undefined
- **THEN** `resolveSession()` returns `null` AND emits `AUTH_BEARER_STATIC_MTLS_REQUIRED`
#### Scenario: mTLS-gated token accepts requests with peer cert
- **WHEN** the same token is presented AND `request.tls.peerCertificate` is present
- **THEN** `resolveSession()` returns the Principal
**Verified by:** `src/mtls/mtls-gate.test.ts::"rejects mtls-required token without peer certificate"` AND `src/mtls/mtls-gate.test.ts::"accepts mtls-required token with peer certificate"`
---
### Requirement: Direct and worker-proxy surfaces both pass conformance
The adapter SHALL pass `runAuthSessionConformance` under both surfaces.
#### Scenario: direct surface conforms
- **WHEN** `runAuthSessionConformance("bearer-static-direct", () => createBearerStaticResolver(...))` runs
- **THEN** every invariant passes
#### Scenario: worker-proxy surface conforms
- **WHEN** `runAuthSessionConformance("bearer-static-worker-proxy", ...)` runs over a real `MessageChannel`
- **THEN** every invariant passes
**Verified by:** `src/factory/create-bearer-static-resolver.spec-conformance.test.ts::"bearer-static-direct conforms to AuthSession contract"` AND `src/proxy/create-bearer-static-resolver-worker-proxy.spec-conformance.test.ts::"bearer-static-worker-proxy conforms to AuthSession contract"`
---
### Requirement: Options Zod-validated
`BearerStaticResolverOptions` MUST be Zod-validated. Empty `tokens` array OR a non-string token entry MUST throw `AUTH_BEARER_STATIC_INVALID_OPTIONS` at factory construction.
#### Scenario: empty tokens array throws
- **WHEN** `createBearerStaticResolver({ tokens: [] })`
- **THEN** a `SuaveplanError` with code `AUTH_BEARER_STATIC_INVALID_OPTIONS` is thrown synchronously
**Verified by:** `src/options/bearer-static-resolver-options.test.ts::"throws on empty tokens array"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,129 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-better-auth (api-auth-better-auth)
**Package**: `@suaveplan/api-auth-better-auth`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: Ships two independently-wireable capability surfaces
The package SHALL export two factory functions — `createBetterAuthSession(opts)` implementing the `auth.session` contract from `@suaveplan/api-auth`, and `createBetterAuthProvider(opts)` implementing the `auth.provider` contract from `@suaveplan/api-auth-provider`. Each MUST be usable independently. Operators MAY wire only the session adapter (keeping their existing account-lifecycle routes elsewhere, e.g. Clerk) OR only the provider adapter (rare) OR both. Wiring only one side MUST NOT mount any routes from the other side.
#### Scenario: session-only wiring exposes no provider routes
- **WHEN** host config mounts `createBetterAuthSession(opts)` but does NOT mount `createBetterAuthProvider`
- **THEN** routes under `routePrefix` (default `/auth/*`) are NOT registered AND `auth.session` is callable
#### Scenario: provider-only wiring exposes no session resolver
- **WHEN** host config mounts `createBetterAuthProvider(opts)` but does NOT mount `createBetterAuthSession`
- **THEN** the provider's account-lifecycle routes are registered AND the `auth.session` slot is filled by whatever other resolver host config supplies (e.g. JWT)
**Verified by:** `src/independence/session-without-provider.test.ts::"session adapter mounts without provider routes"` AND `src/independence/session-without-provider.test.ts::"provider adapter mounts without claiming auth.session"`
---
### Requirement: Better-Auth owns its own DB connection
The package SHALL construct its own Postgres connection (via `BetterAuthOptions.database`) distinct from the host's main DB and from every plugin's DB. Better-Auth's schema (users, sessions, accounts, verification, oauth state) lives in that connection. The wrapper MUST NOT attempt to share the host's metadata DB or any plugin's DB — Better-Auth's opinionated model forbids it and bridging would defeat upstream migrations.
#### Scenario: configured DB is a distinct connection from the host metadata DB
- **WHEN** `BetterAuthOptions.database` is set
- **THEN** the wrapper instantiates its own connection AND does NOT call into `api-host`'s metadata-DB handle
**Verified by:** `tests/integration/postgres-better-auth.test.ts::"Better-Auth uses its own DB connection independent of host metadata DB"`
---
### Requirement: Maps Better-Auth user + session onto our Principal contract
`createBetterAuthSession` SHALL resolve a session via Better-Auth's session APIs, then map the resulting user + session into a `Principal` per `@suaveplan/api-types`. The default mapping populates `id` from `user.id`, `scopes` from `user.roles` (translated via `principalMapping.scopeFromRoles`), `tenantId` from `user.tenantId` (when present), and `claims` from `user` minus its sensitive fields. Operators MAY override the mapping via `BetterAuthOptions.principalMapping`. A mapping failure MUST throw `AUTH_BA_PRINCIPAL_MAPPING_FAILED` (not silently emit a partial principal).
#### Scenario: well-formed Better-Auth user produces a Principal
- **WHEN** Better-Auth returns a session for a known user
- **THEN** `auth.session(request)` returns a `Principal` whose `id` equals `user.id` AND whose `scopes` reflect the role mapping
#### Scenario: mapping failure surfaces explicitly
- **WHEN** the custom `principalMapping` function throws
- **THEN** the session resolver propagates `AUTH_BA_PRINCIPAL_MAPPING_FAILED` AND does NOT return a `null` principal
**Verified by:** `src/session/create-session.test.ts::"maps well-formed Better-Auth user to Principal"` AND `src/session/create-session.test.ts::"mapping failure throws AUTH_BA_PRINCIPAL_MAPPING_FAILED"`
---
### Requirement: Configurable route prefix with collision detection
`createBetterAuthProvider` SHALL mount account-lifecycle routes under `routePrefix` (default `/auth`) using the canonical names from `PROVIDER_ROUTE_SURFACE` (`@suaveplan/api-auth-provider`): `POST /register`, `POST /login`, `POST /logout`, `POST /refresh`, `GET /verify-email/:token`, `GET /oauth/:provider/start`, `GET /oauth/:provider/callback`, `POST /forgot-password`, `POST /reset-password`. Better-Auth's native idiom (`/sign-up`, `/sign-in`, `/sign-out`) is adapted to canonical names inside this package; consumers of the provider see the canonical surface only. The provider MUST refuse to mount if the requested prefix collides with a host-reserved prefix — the reserved-prefix list is sourced from the `@suaveplan/api-host` reserved-prefix contract (NOT hard-coded in this package). Install fails with `AUTH_BA_ROUTE_PREFIX_COLLISION`.
#### Scenario: default prefix mounts every documented route
- **WHEN** the provider mounts with `routePrefix` left at default
- **THEN** every documented route is reachable under `/auth/*`
#### Scenario: collision with reserved prefix is refused
- **WHEN** `routePrefix: "/_host"` is supplied
- **THEN** install fails with `AUTH_BA_ROUTE_PREFIX_COLLISION`
**Verified by:** `src/route-prefix/route-prefix.test.ts::"default prefix mounts every documented route"` AND `src/route-prefix/route-prefix.test.ts::"reserved-prefix collision fails with AUTH_BA_ROUTE_PREFIX_COLLISION"`
---
### Requirement: End-to-end GitHub OAuth flow
The provider SHALL ship a working GitHub OAuth flow as the canonical reference. The full flow — authorization redirect with state cookie, code exchange, user-info fetch, principal mapping, session cookie issue — MUST be exercised end-to-end against a mock OAuth server in an integration test that the spec pins. OAuth state-mismatch MUST fail with `AUTH_BA_OAUTH_STATE_MISMATCH`; provider rejection MUST fail with `AUTH_BA_OAUTH_PROVIDER_REJECTED`.
#### Scenario: full GitHub OAuth flow yields an authenticated session
- **WHEN** the user completes `GET /auth/oauth/github/start` → `GET /auth/oauth/github/callback?code=...&state=...` against the mock OAuth IdP from `@suaveplan/testing-integration-oauth-mock`
- **THEN** a session cookie is set AND a subsequent request resolves to a `Principal` whose claims include the GitHub user id
#### Scenario: state mismatch is rejected
- **WHEN** the callback arrives with a `state` that does not match the issued cookie
- **THEN** the callback responds with `AUTH_BA_OAUTH_STATE_MISMATCH`
**Verified by:** `src/oauth/github-oauth.test.ts::"full GitHub OAuth flow yields authenticated Principal"` AND `src/oauth/github-oauth.test.ts::"state mismatch rejected with AUTH_BA_OAUTH_STATE_MISMATCH"`
---
### Requirement: Conformance suites pass against real Postgres
The package SHALL pass `runAuthSessionConformance` (from `@suaveplan/api-auth/testing`) AND `runAuthProviderConformance` (from `@suaveplan/api-auth-provider/testing`) when its factories are backed by a real Postgres container via `@suaveplan/testing-integration-postgres`.
#### Scenario: session conformance passes
- **WHEN** the suite runs against `createBetterAuthSession(opts)` with Postgres-backed Better-Auth
- **THEN** every invariant in `runAuthSessionConformance` passes
#### Scenario: provider conformance passes
- **WHEN** the suite runs against `createBetterAuthProvider(opts)` with Postgres-backed Better-Auth
- **THEN** every invariant in `runAuthProviderConformance` passes
**Verified by:** `src/session/create-session.spec-conformance.test.ts::"createBetterAuthSession passes runAuthSessionConformance against Postgres"` AND `src/provider/create-provider.spec-conformance.test.ts::"createBetterAuthProvider passes runAuthProviderConformance against Postgres"`
---
### Requirement: OTel surface
The package SHALL emit:
- `auth.better_auth.sessions_resolved_total` counter `{ outcome }` (`"resolved"`, `"null"`, `"error"`).
- `auth.better_auth.session_resolve_duration_ms` histogram.
- `auth.better_auth.oauth_flows_total` counter `{ provider, outcome }`.
- `auth.better_auth.principal_mapping_errors_total` counter.
#### Scenario: successful session resolution increments the counter
- **WHEN** `auth.session(request)` returns a non-null `Principal`
- **THEN** an in-memory `MeterProvider` observes `sessions_resolved_total{outcome="resolved"}` increment by 1
**Verified by:** `src/instruments/instruments.test.ts::"sessions_resolved_total increments on successful resolution"`

View File

@@ -0,0 +1,161 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-auth-jwt
**Package**: `@suaveplan/api-auth-jwt`
**Status**: SHIPPED (0.1.0)
`@suaveplan/api-auth-jwt` is a Stage-4 concrete `SessionResolver` for
the `@suaveplan/api` plugin foundation. It verifies a JWT presented on
the `Authorization: Bearer <jwt>` header (or a configured cookie)
against either a remote JWKS endpoint (TTL + negative-cache + in-flight
dedup + `kid`-miss refresh) or a single static JWK using
`@suaveplan/jws`, then projects the verified claims onto a `Principal`
via an overridable mapper. It is session-resolver-only — no login,
register, logout, or refresh routes — and is intended to be paired with
hosted IdPs (Clerk, Auth0, Cognito) or with any JWT-issuing provider.
## Requirements
### Requirement: Session-resolver-only surface
The package SHALL implement the `SessionResolver` contract from
`@suaveplan/api-auth`. It MUST NOT contribute HTTP routes — login,
logout, and refresh are provider responsibilities and live elsewhere.
#### Scenario: factory returns a SessionResolver
- **WHEN** `createJwtSessionResolver({ staticKey, issuer, audience, algorithms })` is called with valid options
- **THEN** the returned object has `resolve(req): Promise<Principal | null>` and `dispose(): Promise<void>` AND no other methods
**Verified by:** `src/resolver/resolver.test.ts::"resolves a valid Bearer token to a Principal"`
### Requirement: JWT verification against JWKS or static key
The package SHALL verify tokens using `@suaveplan/jws` against either a
JWKS URL (with TTL caching, negative cache, in-flight dedup, and
`kid`-miss refresh) or a single static `JsonWebKey`. The two key
sources are mutually exclusive at construction. Algorithms MUST be
restricted to the allowlist in `options.algorithms`; any token whose
protected-header `alg` is not on the allowlist returns `null`.
#### Scenario: token signed with non-allowlisted alg
- **WHEN** a token signed with `HS256` is presented to a resolver whose `algorithms: ["RS256"]`
- **THEN** `resolve()` returns `null`
#### Scenario: JWKS `kid` miss triggers a refresh
- **WHEN** a token's `kid` is not in the cached JWKS AND the cache entry was served from cache (not freshly fetched)
- **THEN** the JWKS endpoint is refetched exactly once AND the new key is looked up against the refreshed document
**Verified by:** `src/resolver/resolver.test.ts::"returns null when alg is not on the allowlist"` AND `tests/integration/integration.test.ts::"refetches JWKS when kid rotates"`
### Requirement: Conformance to the api-auth SessionResolver contract
The resolver SHALL pass `runAuthSessionConformance` from
`@suaveplan/api-auth/testing`.
#### Scenario: jwt-static-key conformance
- **WHEN** `runAuthSessionConformance` runs over a resolver built with `staticKey` (HS256)
- **THEN** every invariant passes (null on unrecognised request, expected `Principal` on recognised request, idempotent `dispose`)
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts`
### Requirement: Claim → Principal projection is overridable
The package SHALL provide a `defaultClaimsToPrincipal` mapper that
projects `sub` → `id`, `scope` (string) or `scopes` (array) → `scopes`,
`tenant_id` or `tid` → `tenantId`, and the entire claims record →
`claims`. Callers MAY supply `options.claimsToPrincipal` to override.
A mapper that throws or returns a non-Principal value MUST raise
`AUTH_JWT.INVALID_CLAIMS_MAPPER`.
#### Scenario: custom mapper reads non-standard claim
- **WHEN** `options.claimsToPrincipal` reads `org_id` instead of `tenant_id`
- **THEN** the produced `Principal.tenantId` reflects `org_id`
**Verified by:** `src/claims-mapper/claims-mapper.test.ts` AND `src/resolver/resolver.test.ts::"supports custom claimsToPrincipal"`
### Requirement: Token extraction honours header and cookie sources
The package SHALL extract the token from the configured header (default
`Authorization: Bearer <jwt>`) or from a cookie named via
`options.cookieName`. If both are present the header MUST win. The
header name is configurable; non-standard header names accept a raw
token form (no `Bearer ` prefix required).
#### Scenario: cookie path yields a Principal
- **WHEN** `cookieName: "tok"` is configured AND a request carries `Cookie: tok=<jwt>`
- **THEN** the resolver returns the projected `Principal`
#### Scenario: header takes precedence over cookie
- **WHEN** a request carries BOTH a valid Bearer header AND a valid cookie under `tok`
- **THEN** the Principal reflects the header token's claims
**Verified by:** `src/resolver/resolver.test.ts::"reads from cookie when configured"` AND `src/resolver/resolver.test.ts::"header wins over cookie when both are present"`
### Requirement: Options Zod-validated
`JwtSessionResolverOptions` MUST be Zod-validated at construction.
Supplying neither `jwksUrl` nor `staticKey`, or supplying both, MUST
throw `AUTH_JWT.INVALID_KEY_FORMAT`. An empty `algorithms` allowlist
MUST also throw `AUTH_JWT.INVALID_KEY_FORMAT`.
#### Scenario: missing key source throws
- **WHEN** `createJwtSessionResolver({ issuer, audience, algorithms })`
- **THEN** a `AuthJwtError` with code `AUTH_JWT.INVALID_KEY_FORMAT` is thrown synchronously
**Verified by:** `src/options/options.test.ts::"rejects when neither jwksUrl nor staticKey is supplied"`
### Requirement: Temporal claims enforcement
The resolver SHALL reject tokens whose `exp` has elapsed (relative to
the injected `clock`, with `clockToleranceSec` skew) AND whose `nbf`
has not yet been reached. Issuer (`iss`) and audience (`aud`) MUST be
checked against the configured allowlist; string and array forms of
`aud` are both supported.
#### Scenario: expired token rejected
- **WHEN** a token with `exp` in the past (beyond tolerance) is presented
- **THEN** the resolver returns `null`
**Verified by:** `src/resolver/resolver.test.ts::"rejects expired token"` AND `src/resolver/resolver.test.ts::"rejects not-yet-valid token"` AND `src/resolver/resolver.test.ts::"honours clockToleranceSec for slightly expired tokens"`
### Requirement: OpenAPI security-scheme contribution
The package SHALL export a `SecuritySchemeContribution` (from
`@suaveplan/api-auth`) for the canonical Bearer JWT scheme
(`http` / `bearer` / `bearerFormat: "JWT"`). The contribution name MUST
default to `bearerAuth` and MUST be renameable.
#### Scenario: renamed contribution
- **WHEN** `createJwtBearerSecurityScheme("userAuth")` is called
- **THEN** the returned contribution's `name` is `"userAuth"` AND `validateSecurityScheme` accepts it
**Verified by:** `src/security-scheme/security-scheme.test.ts`
### Requirement: Infrastructure failures propagate, soft failures collapse to null
JWKS endpoint failures (unreachable, non-2xx, non-JSON body, missing
keys array) MUST raise `AUTH_JWT.JWKS_FETCH_FAILED`. Static-key shape
failures MUST raise `AUTH_JWT.INVALID_KEY_FORMAT`. Claims mapper bugs
MUST raise `AUTH_JWT.INVALID_CLAIMS_MAPPER`. Every other per-request
failure MUST collapse to `null` so the upstream `SessionResolverChain`
can fall through to the next adapter.
#### Scenario: 500 on JWKS endpoint propagates
- **WHEN** the JWKS endpoint returns HTTP 500 AND no cached document is available
- **THEN** `resolve()` throws `AuthJwtError` with code `AUTH_JWT.JWKS_FETCH_FAILED`
**Verified by:** `tests/integration/integration.test.ts::"propagates JWKS_FETCH_FAILED when endpoint returns 500"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,127 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-provider (api-auth-provider)
**Package**: `@suaveplan/api-auth-provider`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: AuthProviderAdapter contract
The package SHALL export `AuthProviderAdapter` interface: `pathPrefix: string` (default `/auth`), `routes(router: Router, ctx: PluginContext): void` to register provider routes, `lifecycle: { install, start, stop, uninstall }` hooks matching the generic plugin lifecycle. The adapter MUST be generic over `Caps extends CapabilityMap`.
#### Scenario: every documented field present
- **WHEN** `expectTypeOf<AuthProviderAdapter>()` is checked
- **THEN** `pathPrefix`, `routes`, and the four lifecycle hooks are declared
**Verified by:** `src/provider/provider.spec-conformance.test.ts::"AuthProviderAdapter declares pathPrefix routes lifecycle"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: Canonical provider route surface
The package SHALL export `PROVIDER_ROUTE_SURFACE` listing the canonical paths a provider OWNS (relative to `pathPrefix`): `/register`, `/login`, `/logout`, `/refresh`, `/oauth/:provider/start`, `/oauth/:provider/callback`, `/verify-email/:token`, `/forgot-password`, `/reset-password`. Providers MAY implement a subset; the host does not require every path.
#### Scenario: surface enumerates the documented paths
- **WHEN** the test inspects `PROVIDER_ROUTE_SURFACE`
- **THEN** every documented path is listed
**Verified by:** `src/route-surface/route-surface.test.ts::"canonical surface lists every documented path"`
---
### Requirement: Path-ownership registry
The package SHALL export `PathOwnership` — a runtime registry. `register(provider)` claims `provider.pathPrefix`. A second `register(provider2)` with a different provider MUST throw `AUTH_PROVIDER.MULTIPLE_PROVIDERS_REGISTERED`. Re-registering the SAME provider instance MUST be a no-op (idempotent).
#### Scenario: double registration with different provider rejected
- **WHEN** provider A is registered AND provider B is registered
- **THEN** the second call throws `AUTH_PROVIDER.MULTIPLE_PROVIDERS_REGISTERED`
#### Scenario: re-registering same provider is idempotent
- **WHEN** provider A is registered twice
- **THEN** the second call returns without throwing
**Verified by:** `src/path-ownership/path-ownership.test.ts::"double registration rejected"` AND `src/path-ownership/path-ownership.test.ts::"re-registering same provider is idempotent"`
---
### Requirement: Collision detector
The package SHALL export `detectAuthPathCollisions({ provider, plugins }): CollisionReport`. A plugin route whose prefix overlaps with `provider.pathPrefix` AND is not in `publicRoutes` MUST be reported with code `AUTH_PROVIDER.PATH_COLLISION` AND `details.plugin` AND `details.path`. Plugin routes outside the auth prefix MUST NOT be reported.
#### Scenario: overlap reported
- **WHEN** provider owns `/auth` AND plugin declares route `/auth/custom`
- **THEN** the report includes `{ code: "AUTH_PROVIDER.PATH_COLLISION", details: { plugin: ..., path: "/auth/custom" } }`
#### Scenario: non-overlap not reported
- **WHEN** provider owns `/auth` AND plugin declares route `/billing/invoices`
- **THEN** the report is empty
#### Scenario: configurable prefix shifts collision domain
- **WHEN** provider's `pathPrefix === "/myauth"` AND plugin declares route `/auth/x`
- **THEN** the report is empty AND a plugin route `/myauth/x` would be reported
**Verified by:** `src/collision-detector/collision-detector.test.ts::"overlap with auth prefix reported"` AND `src/collision-detector/collision-detector.test.ts::"non-overlap not reported"` AND `src/collision-detector/collision-detector.test.ts::"configurable prefix shifts collision domain"`
---
### Requirement: CapabilityId augmentation contributes "auth.provider"
The package SHALL augment the `CapabilityId` union to include `"auth.provider"`.
#### Scenario: auth.provider assignable to CapabilityId
- **WHEN** `expectTypeOf<"auth.provider">().toMatchTypeOf<CapabilityId>()` is checked
- **THEN** the assertion passes
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"auth.provider is a valid CapabilityId after augmentation"`
---
### Requirement: Error codes registry frozen
The package SHALL export `AUTH_PROVIDER_CODES` containing exactly: `PATH_COLLISION`, `MULTIPLE_PROVIDERS_REGISTERED`, `PROVIDER_LIFECYCLE_FAILED`, `OAUTH_CALLBACK_INVALID_STATE`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `AUTH_PROVIDER_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every AUTH_PROVIDER_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for providers
The package SHALL export `runAuthProviderConformance({ factory, label })`. Every concrete provider in Stages 6/8 MUST pass it.
#### Scenario: harness covers path ownership, route surface, lifecycle, collision
- **WHEN** the harness runs against a stub provider
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers path ownership, route surface, lifecycle, collision"`

View File

@@ -0,0 +1,153 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-rolled (api-auth-rolled)
**Package**: `@suaveplan/api-auth-rolled`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> **Scope note (Stage 8.12 keystone):** this change ships the rolled
> **session-resolver** half of the eventual dual contract. Issues RS256 JWTs
> with rotating `kid` slots, projects DB rows onto the `Principal` contract,
> and contributes cookie + bearer OpenAPI security schemes. The matching
> `AuthProviderAdapter` half (account-lifecycle routes, password gating,
> MFA, OAuth) is deferred to a successor change `api-auth-rolled-provider`
> so this milestone can ship at 100% coverage in a single iteration.
## ADDED Requirements
### Requirement: Implements `auth.session`
The package SHALL expose `createRolledAuthAdapter` returning an
`AuthAdapter` (session resolver + security scheme contribution).
#### Scenario: adapter exposes the auth.session contract
- WHEN `createRolledAuthAdapter({ store, keys, issuer, audience })` is called
- THEN the returned value implements `{ resolver, securityScheme }` per
`@suaveplan/api-auth/session-adapter`
**Verified by:** `src/session/session.test.ts::"adapter exposes resolver and securityScheme"`
---
### Requirement: Session tokens are RS256-signed JWTs with rotating `kid`
The package SHALL issue session tokens as RS256 JWTs signed via
`@suaveplan/crypto-rsa` + `@suaveplan/jws`. Keys are identified by `kid`
so rotation is possible without invalidating in-flight sessions. The
`TokenRotator` manages an active key (signs) and retired-but-non-revoked
keys (verify only). Expired tokens cause `resolveSession` to return null
(soft failure per `SessionResolver` chain semantics) — the host's chain
then emits 401.
#### Scenario: expired token rejected
- WHEN a JWT whose `exp` is in the past is presented
- THEN `resolveSession` returns null
#### Scenario: token signed by retired key still verifies
- WHEN a JWT signed under `kid=k0` is presented after the active key has
rotated to `kid=k1`, and `k0` is retired but not revoked
- THEN `resolveSession` returns the projected principal
#### Scenario: token signed by revoked key rejected
- WHEN a JWT whose `kid` is revoked is presented
- THEN `resolveSession` returns null
**Verified by:** `src/session/session.test.ts::"expired token returns null"`
AND `src/session/session.test.ts::"retired but non-revoked kid still verifies"`
AND `src/session/session.test.ts::"revoked kid rejected"`
---
### Requirement: Principal projection maps DB rows to api-types `Principal`
The package SHALL project each authenticated user row into the
`Principal` shape from `api-types` (`id`, `scopes`, optional `tenantId`,
`claims`).
#### Scenario: projection includes scopes and tenantId
- WHEN a user with scopes `["billing:read"]` and `tenantId = "acme"` is
resolved
- THEN `Principal` reflects those values exactly AND no extra fields leak
**Verified by:** `src/principal-projection/principal-projection.test.ts::"projects scopes and tenantId"`
---
### Requirement: Shares host's main DB connection (no separate connection)
The package SHALL accept a `store` adapter passed in by the host and MUST
NOT open its own DB connection. The store interface is structural and
satisfied by any storage layer the host wires.
#### Scenario: adapter requires store at construction
- WHEN `createRolledAuthAdapter({})` is called without `store`
- THEN it throws `AuthRolledError(DATABASE_REQUIRED)`
**Verified by:** `src/options/options.test.ts::"throws DATABASE_REQUIRED when store is missing"`
---
### Requirement: Comprehensive error code coverage
The package SHALL throw at least one production path per error code in
`AUTH_ROLLED_*`. The composition test enumerates the registry and asserts
every code has a production trigger.
#### Scenario: every error code is triggered by at least one production path
- WHEN the conformance test enumerates `AUTH_ROLLED_*`
- THEN each code maps to at least one named production scenario
**Verified by:** `src/composition/composition.spec-conformance.test.ts::"every AUTH_ROLLED code is triggered"`
---
### Requirement: Contributes OpenAPI security schemes
The package SHALL contribute `securitySchemes` to `api-openapi` describing
the cookie + bearer schemes.
#### Scenario: schemes are picked up by emitter
- WHEN the adapter is inspected
- THEN `securityScheme.schemes.bearer` and `securityScheme.schemes.cookie`
are present and validate against `OpenApiSecuritySchemeSchema`
**Verified by:** `src/security-schemes/security-schemes.test.ts::"contributes cookie and bearer schemes"`
---
### Requirement: Rotating-key TokenRotator
The package SHALL expose a `TokenRotator` that surfaces `snapshot()`,
`decide()` (advisory rotation timing), `setKeys()` (operator-driven swap)
and `publicJwkFor(kid)` (verifier lookup). The rotator MUST enforce
exactly-one-active-non-revoked on every keyset swap.
#### Scenario: setKeys with no active key rejected
- WHEN `setKeys([key("k1", active=false)])` is called
- THEN it throws `AuthRolledError(KEY_ROTATION_FAILED)`
#### Scenario: setKeys with two active keys rejected
- WHEN a replacement keyset contains two active+non-revoked keys
- THEN `setKeys` throws `AuthRolledError(KEY_ROTATION_FAILED)`
#### Scenario: decide reports shouldRotate=true after period elapses
- WHEN `rotationPeriodMs = 1000` and 1500 ms elapse on the injected clock
- THEN `decide().shouldRotate === true` AND `decide().retiringKid` equals
the active kid
**Verified by:** `src/token-rotator/token-rotator.test.ts::"setKeys rejects a replacement with zero active keys"`
AND `src/token-rotator/token-rotator.test.ts::"setKeys rejects a replacement with two active keys"`
AND `src/token-rotator/token-rotator.test.ts::"decide returns shouldRotate=true once the period elapses"`

View File

@@ -0,0 +1,77 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-static-principal (api-auth-static-principal)
**Package**: `@suaveplan/api-auth-static-principal`
**Status**: SHIPPED
## ADDED Requirements
### Requirement: resolveSession returns the configured Principal verbatim
The package SHALL return the exact `Principal` from `options.principal` on every `resolveSession()` call, regardless of request shape. The returned Principal MUST deep-equal the configured one (no mutation, no reference-leak across calls).
#### Scenario: every request resolves to the configured Principal
- **WHEN** the adapter is configured with `principal: { id: "u1", scopes: ["x:write"], tenantId: "acme" }` AND two distinct requests are processed
- **THEN** both `resolveSession()` calls return Principals that deep-equal the configured one
#### Scenario: returned Principal is not the same reference each call
- **WHEN** two `resolveSession()` calls are made
- **THEN** the returned objects are deep-equal AND callers cannot mutate one and affect the other (defensive clone OR documented frozen reference — pin one)
**Verified by:** `src/factory/create-static-principal-resolver.test.ts::"every request resolves to the configured Principal"` AND `src/factory/create-static-principal-resolver.test.ts::"returned Principal is isolated across calls"`
---
### Requirement: nullForPaths overrides specific routes
When `options.nullForPaths` is set, `resolveSession()` SHALL return `null` for requests whose path matches any entry (exact match or glob). Used to test `publicRoutes` behaviour without changing adapter wiring.
#### Scenario: matching path returns null
- **WHEN** `nullForPaths: ["/webhooks/*"]` is configured AND the request path is `/webhooks/stripe`
- **THEN** `resolveSession()` returns `null`
#### Scenario: non-matching path returns the Principal
- **WHEN** `nullForPaths: ["/webhooks/*"]` is configured AND the request path is `/billing/invoices`
- **THEN** `resolveSession()` returns the configured Principal
**Verified by:** `src/factory/create-static-principal-resolver.test.ts::"matching path in nullForPaths returns null"` AND `src/factory/create-static-principal-resolver.test.ts::"non-matching path returns the configured Principal"`
---
### Requirement: Conforms to AuthSession contract
#### Scenario: conformance suite passes
- **WHEN** `runAuthSessionConformance("static-principal-direct", () => createStaticPrincipalResolver({ principal: P }))` runs
- **THEN** every invariant passes
**Verified by:** `src/factory/create-static-principal-resolver.spec-conformance.test.ts::"static-principal-direct conforms to AuthSession contract"`
---
### Requirement: Options Zod-validated
#### Scenario: missing principal.id throws
- **WHEN** `createStaticPrincipalResolver({ principal: { scopes: [], claims: {} } as never })`
- **THEN** a `SuaveplanError` with code `AUTH_STATIC_PRINCIPAL_INVALID_OPTIONS` is thrown synchronously
**Verified by:** `src/options/static-principal-resolver-options.test.ts::"throws when principal.id is missing"`
---
### Requirement: Registered in `api-host/testing` adapter table
#### Scenario: createTestHost resolves the kind sentinel
- **WHEN** `createTestHost({ adapters: { "auth.session": { kind: "static-principal", principal: P } } })`
- **THEN** the resulting `host.auth` is an instance produced by this factory
**Verified by:** `tests/integration/integration.test.ts::"createTestHost resolves kind:\"static-principal\" to this adapter"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,132 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-auth
**Package**: `@suaveplan/api-auth`
**Status**: SHIPPED (0.1.0)
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: SessionResolver contract
The package SHALL export `SessionResolver` interface: `resolve(req: Request): Promise<Principal | null>`, `dispose(): Promise<void>`. A resolver returning `null` means "I don't recognise this request" — NOT "auth failed." Auth failure (401) is the host's decision based on chain outcome + route's `publicRoutes` membership.
#### Scenario: every method present
- **WHEN** `expectTypeOf<SessionResolver>()` is checked
- **THEN** `resolve` and `dispose` are declared with the documented signatures
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts::"SessionResolver declares resolve and dispose"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: Resolver chain — first-match-wins
The package SHALL export `SessionResolverChain(resolvers: readonly SessionResolver[]).resolve(req): Promise<Principal | null>` running each resolver in order; the first non-null result wins; if all return null, the chain returns null. An empty chain MUST throw `AUTH.CHAIN_EMPTY` at construction. By default, a resolver throwing MUST surface `AUTH.RESOLVER_THREW` AND short-circuit the chain (fail-closed). An opt-in `continueOnThrow: true` flag lets the chain try the next resolver; this is documented as escape-hatch only.
#### Scenario: first non-null wins
- **WHEN** chain `[null, principalA, principalB]` is run
- **THEN** the chain returns `principalA` AND resolver B is NOT called
#### Scenario: all null returns null
- **WHEN** every resolver returns null
- **THEN** the chain returns null
#### Scenario: throw is fail-closed by default
- **WHEN** resolver A throws AND `continueOnThrow` is false (default)
- **THEN** the chain throws `AUTH.RESOLVER_THREW` AND resolver B is NOT called
#### Scenario: empty chain rejected at construction
- **WHEN** `new SessionResolverChain([])` is called
- **THEN** it throws `AUTH.CHAIN_EMPTY`
**Verified by:** `src/chain/chain.test.ts::"first non-null wins, later resolvers skipped"` AND `src/chain/chain.test.ts::"all null returns null"` AND `src/chain/chain.test.ts::"throw is fail-closed by default"` AND `src/chain/chain.test.ts::"empty chain rejected at construction"`
---
### Requirement: SecuritySchemeContribution
The package SHALL export `SecuritySchemeContribution` with `{ name: string, scheme: OpenApiSecurityScheme }`. Every concrete `SessionResolver` MUST contribute exactly one scheme so `api-openapi` can emit the right `securitySchemes` block. An `AuthAdapter` whose `securityScheme` is absent at registration MUST surface `AUTH.SECURITY_SCHEME_MISSING`.
#### Scenario: missing scheme rejected
- **WHEN** an `AuthAdapter` without `securityScheme` is registered
- **THEN** registration throws `AUTH.SECURITY_SCHEME_MISSING`
**Verified by:** `src/security-scheme/security-scheme.test.ts::"registration rejects adapter without securityScheme"`
---
### Requirement: CSRF default policy
The package SHALL export `CsrfPolicy.requiresCsrf(scheme: OpenApiSecurityScheme): boolean`. Cookie-based schemes (`type: "apiKey"` AND `in: "cookie"`) default ON; JWT/Bearer schemes (`type: "http"` AND `scheme: "bearer"`) default OFF. Per-route `csrf: false` opt-out is honoured by the host (this package exposes the default; the host applies the override).
#### Scenario: cookie scheme defaults to CSRF ON
- **WHEN** `requiresCsrf({ type: "apiKey", in: "cookie", ... })` is called
- **THEN** the result is `true`
#### Scenario: bearer scheme defaults to CSRF OFF
- **WHEN** `requiresCsrf({ type: "http", scheme: "bearer" })` is called
- **THEN** the result is `false`
**Verified by:** `src/csrf-policy/csrf-policy.test.ts::"cookie schemes default to CSRF ON"` AND `src/csrf-policy/csrf-policy.test.ts::"bearer schemes default to CSRF OFF"`
---
### Requirement: CapabilityId augmentation contributes "auth.session"
The package SHALL augment the `CapabilityId` union to include `"auth.session"`.
#### Scenario: auth.session assignable to CapabilityId
- **WHEN** `expectTypeOf<"auth.session">().toMatchTypeOf<CapabilityId>()` is checked
- **THEN** the assertion passes
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"auth.session is a valid CapabilityId after augmentation"`
---
### Requirement: Error codes registry frozen
The package SHALL export `AUTH_CODES` containing exactly: `RESOLVER_THREW`, `CHAIN_EMPTY`, `PRINCIPAL_INVALID_SHAPE`, `SECURITY_SCHEME_MISSING`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `AUTH_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every AUTH_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for resolvers
The package SHALL export `runAuthSessionConformance({ factory, label })`. Every concrete resolver in Stages 4/6/8 MUST pass it.
#### Scenario: harness covers resolve null, resolve principal, dispose
- **WHEN** the harness is run against a stub resolver
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers resolve null, resolve principal, dispose lifecycle"`

View File

@@ -0,0 +1,117 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-db-adapter (api-db-adapter)
**Package**: `@suaveplan/api-db-adapter`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Admin surface exposes raw DatabaseLike
The package SHALL expose `AdminDatabaseLike` (per `@suaveplan/api-types`) with `query`, `execute`, `transaction`, `prepare` (for engines that support it). Admin surface is used for migrations and operator tooling; it does NOT enforce tenant scoping.
#### Scenario: admin query returns full result set
- **WHEN** admin runs `SELECT * FROM users` against a fixture seeded with rows across two tenants
- **THEN** the result contains rows from both tenants (no filter applied)
**Verified by:** `src/factory/create-db-adapter.test.ts::"admin surface returns rows across all tenants"`
---
### Requirement: Tenant-scoped wrapper rejects unscoped queries
`createTenantScopedWrapper(admin, tenantId, options)` SHALL return a `TenantScopedDatabaseLike` that:
1. Injects `WHERE tenantColumn = $1` (default column `tenant_id`) on every SELECT/UPDATE/DELETE.
2. Injects `tenantColumn` on every INSERT — if the payload lacks the column AND `autoInjectInserts: true`, inject the value; if `autoInjectInserts: false`, reject with `DB_ADAPTER_TENANT_SCOPE_VIOLATION`.
3. Rejects raw SQL that doesn't reference the tenant column on a tenant-scoped table.
#### Scenario: SELECT auto-scopes to current tenant
- **WHEN** a route handler runs `SELECT * FROM users` against the scoped wrapper for `tenant="acme"`
- **THEN** the executed SQL contains `WHERE tenant_id = 'acme'` (or equivalent prepared parameter) AND no rows from other tenants are returned
#### Scenario: cross-tenant write is rejected
- **WHEN** the scoped wrapper for `tenant="acme"` is asked to `INSERT INTO users (tenant_id) VALUES ('bigcorp')`
- **THEN** the call throws `DB_ADAPTER_TENANT_SCOPE_VIOLATION`
**Verified by:** `src/tenant-scoping/create-tenant-scoped-wrapper.test.ts::"SELECT auto-scopes to current tenant"` AND `src/tenant-scoping/create-tenant-scoped-wrapper.test.ts::"INSERT with conflicting tenant_id is rejected"`
---
### Requirement: db.unsafe escape hatch is capability-gated
The scoped wrapper SHALL expose `db.unsafe.query(sql, params)` which bypasses tenant injection. Calls MUST be allowed ONLY if the plugin's manifest declares the `db.unsafe-queries` capability; otherwise throw `DB_ADAPTER_UNSAFE_NOT_PERMITTED`. Every allowed call MUST emit `plugin.unsafe_query_total{plugin}`.
#### Scenario: plugin without declaration is blocked
- **WHEN** a route handler whose plugin's manifest lacks `db.unsafe-queries` calls `ctx.db.unsafe.query(...)`
- **THEN** the call throws `DB_ADAPTER_UNSAFE_NOT_PERMITTED` AND nothing is executed against the engine
#### Scenario: plugin with declaration is allowed and counted
- **WHEN** a plugin declaring `db.unsafe-queries` calls `ctx.db.unsafe.query("SELECT * FROM audit_log")`
- **THEN** the query runs AND `plugin.unsafe_query_total{plugin=<name>}` increments by 1
**Verified by:** `src/unsafe/unsafe-query-gate.test.ts::"throws when capability not declared"` AND `src/unsafe/unsafe-query-gate.test.ts::"emits plugin.unsafe_query_total when allowed"`
---
### Requirement: Per-engine migration runner
`runMigrations(plugin, capabilities)` SHALL apply pending migrations from `manifest.database.migrations` in lexicographic order, recording applied versions in `__plugin_migrations(plugin, version, applied_at)`. Re-running with no new files MUST be a no-op (idempotent).
Supported engines: Postgres, SQLite, Surreal. Each engine SHALL apply migrations within a transaction where the engine supports it; on failure the migration rolls back AND `DB_ADAPTER_MIGRATION_FAILED` is thrown with the failing file path.
#### Scenario: forward migration applies once
- **WHEN** `runMigrations` is called with migrations `[001_init.sql, 002_add_invoices.sql]` AND `__plugin_migrations` is empty
- **THEN** both files run AND `__plugin_migrations` records two rows AND `api.db.migrations_applied_total{plugin}` records 2
#### Scenario: idempotent re-run
- **WHEN** `runMigrations` is called twice in a row with the same file set
- **THEN** the second call applies zero migrations AND `api.db.migrations_applied_total` does not increment on the second call
#### Scenario: failure rolls back
- **WHEN** `002_add_invoices.sql` contains a syntax error
- **THEN** the call throws `DB_ADAPTER_MIGRATION_FAILED` with `details.file = "002_add_invoices.sql"` AND `__plugin_migrations` does NOT record version 002
**Verified by:** `src/migrations/run-migrations.test.ts::"applies pending migrations in lexicographic order"` AND `src/migrations/run-migrations.test.ts::"is idempotent on re-run"` AND `src/migrations/run-migrations.test.ts::"rolls back and throws on migration failure"`
---
### Requirement: Direct and worker-proxy surfaces both pass conformance
The worker-proxy variant MUST preserve `ReadableStream`-based cursors across the `MessagePort` boundary (transferable, zero-copy where the runtime supports it).
#### Scenario: direct conforms
- **WHEN** `runDbAdapterConformance("db-direct", () => createDbAdapter(...))` runs against an in-memory SQLite
- **THEN** every invariant passes
#### Scenario: worker-proxy conforms with streaming cursors
- **WHEN** `runDbAdapterConformance("db-worker-proxy", ...)` runs over a real `MessageChannel` AND a query returns a cursor of 10_000 rows
- **THEN** every invariant passes AND the cursor is consumed on the proxy side without buffering all rows in memory at once
**Verified by:** `src/factory/create-db-adapter.spec-conformance.test.ts::"db-direct conforms to Db contract"` AND `src/proxy/create-db-adapter-worker-proxy.spec-conformance.test.ts::"db-worker-proxy conforms to Db contract with streaming cursors"`
---
### Requirement: Engine selection is Zod-validated
`DbAdapterOptions.engine` MUST be one of `"postgres" | "sqlite" | "surrealdb"`. Any other value MUST throw `DB_ADAPTER_ENGINE_UNSUPPORTED` at construction.
#### Scenario: unsupported engine throws
- **WHEN** `createDbAdapter({ engine: "mongodb" as never, url: "..." })`
- **THEN** the factory throws `DB_ADAPTER_ENGINE_UNSUPPORTED` synchronously
**Verified by:** `src/options/db-adapter-options.test.ts::"throws on unsupported engine value"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,79 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-db-sqlite-memory (api-db-sqlite-memory)
**Package**: `@suaveplan/api-db-sqlite-memory`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Backed by real bun:sqlite :memory:
The package SHALL open a real `bun:sqlite` `Database(":memory:")` and route every query/execute/prepare through it. No SQL emulation, no in-process query interpreter.
#### Scenario: PRAGMA foreign_keys is honoured
- **WHEN** the adapter is constructed with default pragmas AND a parent/child table pair has a `FOREIGN KEY` constraint AND a child insert references a non-existent parent
- **THEN** the insert fails with the standard SQLite foreign-key violation (proving real SQLite is in the loop)
**Verified by:** `src/factory/create-sqlite-memory-db.test.ts::"PRAGMA foreign_keys is honoured by the real bun:sqlite database"`
---
### Requirement: Admin and tenant-scoped surfaces
The factory SHALL return `{ admin, scoped(tenantId), close }`. The `scoped` wrapper enforces `WHERE tenant_id = $1` on reads and rejects writes whose payload's `tenant_id` differs from the wrapper's bound tenant (or auto-injects when `autoInjectInserts: true`).
#### Scenario: admin reads across tenants
- **WHEN** admin runs `SELECT * FROM users` against a multi-tenant fixture
- **THEN** rows from all tenants are returned
#### Scenario: scoped rejects cross-tenant insert
- **WHEN** `scoped("acme")` attempts `INSERT INTO users (tenant_id) VALUES ('bigcorp')`
- **THEN** the call throws `DB_SQLITE_MEMORY_TENANT_SCOPE_VIOLATION`
**Verified by:** `src/tenant-scoping/tenant-scoping.test.ts::"admin reads across tenants"` AND `src/tenant-scoping/tenant-scoping.test.ts::"scoped rejects cross-tenant insert"`
---
### Requirement: Migration runner
The adapter SHALL run migrations from `migrationsDir` in lexicographic order, record applied versions in `__plugin_migrations`, and remain idempotent on re-run. Failures throw `DB_SQLITE_MEMORY_MIGRATION_FAILED` with the failing file path.
#### Scenario: forward migration applies once
- **WHEN** `runMigrations` is called with a 2-file directory AND `__plugin_migrations` is empty
- **THEN** both files run AND `__plugin_migrations` has 2 rows
#### Scenario: idempotent re-run
- **WHEN** `runMigrations` is called a second time
- **THEN** zero new migrations run AND no error is thrown
**Verified by:** `src/migrations/migrations.test.ts::"applies pending migrations in lexicographic order"` AND `src/migrations/migrations.test.ts::"is idempotent on re-run"`
---
### Requirement: Conforms to Db contract
#### Scenario: conformance suite passes
- **WHEN** `runDbAdapterConformance("sqlite-memory-direct", () => createSqliteMemoryDb(...))` runs
- **THEN** every invariant passes
**Verified by:** `src/factory/create-sqlite-memory-db.spec-conformance.test.ts::"sqlite-memory-direct conforms to Db contract"`
---
### Requirement: Registered in `api-host/testing` adapter table
#### Scenario: createTestHost resolves the engine sentinel
- **WHEN** `createTestHost({ adapters: { db: { engine: "sqlite-memory" } } })`
- **THEN** the resulting `host.db` is an instance produced by this factory
**Verified by:** `tests/integration/integration.test.ts::"createTestHost resolves engine:\"sqlite-memory\" to this adapter"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,88 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: `api-db-worker-proxy`
**Package**: `@suaveplan/api-db-worker-proxy`
**Status**: SHIPPED (2026-05-25, v0.1.0)
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Admin and tenant-scoped surfaces are type-distinct
The package SHALL expose two distinct worker-side surface types: `DbProxyClient` (extends `AdminDatabaseLike`) returned by `createDbProxyClient(port, { engine })`, and `TenantScopedDatabaseLike` returned by `wrapTenantScoped(adminProxy, tenantId)`. The two return types MUST NOT be mutually assignable: a tenant-scoped proxy MUST NOT satisfy an admin parameter and vice versa. Tenant scoping is enforced on the MAIN side; the worker-side facade is the type-level guarantee that the request slot has been narrowed to a tenant.
#### Scenario: surfaces are not mutually assignable
- **WHEN** `expectTypeOf<DbProxyClient>().not.toMatchTypeOf<TenantScopedDatabaseLike>()` is checked
- **THEN** TypeScript flags the surfaces as distinct
**Verified by:** `src/worker-side/worker-side.spec-conformance.test.ts::"DbProxyClient is NOT assignable to TenantScopedDatabaseLike"` AND `src/worker-side/worker-side.spec-conformance.test.ts::"wrapTenantScoped output is TenantScopedDatabaseLike, NOT AdminDatabaseLike"`
---
### Requirement: Cursors stream results without buffering
The package SHALL marshal DB cursors as `AsyncIterable<Row>` via `@suaveplan/api-worker-rpc`'s `streamRequest` primitive. `for await (const row of cursor)` MUST yield rows incrementally; the main-side cursor MUST NOT be drained eagerly. Consumer cancellation via `AbortSignal` MUST propagate so the main-side cursor observes `signal.aborted` and stops producing rows.
#### Scenario: large cursor yields incrementally
- **WHEN** a worker iterates a 1000-row cursor
- **THEN** every row is observed in order without preallocating the full result
#### Scenario: cursor cancellation propagates
- **WHEN** the worker aborts an `AbortController` mid-iteration
- **THEN** the main-side cursor observes the abort and ceases yielding
**Verified by:** `src/main-side/main-side.test.ts::"cursor streams rows in order"` AND `src/main-side/main-side.test.ts::"cursor cancellation aborts the main-side iterator"` AND `tests/conformance/run-db-adapter-conformance.test.ts::"cursor: 1000-row stream yields incrementally"`
---
### Requirement: Transaction handle is scoped to callback lifetime
The package SHALL provide `client.transaction(async (tx) => { ... })` where `tx` is only valid inside the callback. A call on `tx` after the callback resolves MUST throw `DB_PROXY.TRANSACTION_LOST`. Auto-commit fires on callback resolve; auto-rollback fires on callback reject. Explicit `tx.commit()` or `tx.rollback()` inside the callback suppresses the auto-action.
#### Scenario: out-of-scope tx call throws
- **WHEN** the callback captures `tx` AND a method is called on it after the callback returns
- **THEN** `DB_PROXY.TRANSACTION_LOST` is thrown
#### Scenario: callback throw triggers rollback
- **WHEN** the callback throws
- **THEN** the worker-side wrapper invokes `tx.rollback` on the main side AND re-throws the original error
**Verified by:** `src/worker-side/worker-side.test.ts::"tx handle leaked outside callback throws TRANSACTION_LOST"` AND `src/worker-side/worker-side.test.ts::"transaction auto-rolls-back when callback throws"` AND `src/worker-side/worker-side.test.ts::"auto-rollback path swallows rollback errors"`
---
### Requirement: Error namespace is the `DB_PROXY.*` registry
The package SHALL expose a stable error-code registry `DB_PROXY_CODES` containing exactly four codes: `UNSAFE_NOT_PERMITTED`, `TRANSACTION_LOST`, `STREAM_INTERRUPTED`, `PROXY_DISPOSED`. Every public failure path MUST raise a `DbProxyError` carrying one of these codes; consumers branch on the literal value, not the message.
#### Scenario: PROXY_DISPOSED after dispose
- **WHEN** `dispose()` is called and a subsequent method is invoked
- **THEN** the method throws `DbProxyError` with code `DB_PROXY.PROXY_DISPOSED`
#### Scenario: UNSAFE_NOT_PERMITTED when adapter lacks capability
- **WHEN** the underlying adapter does not implement `prepare` or `transaction`
- **THEN** the proxy throws `DbProxyError` with code `DB_PROXY.UNSAFE_NOT_PERMITTED`
**Verified by:** `src/error-codes/error-codes.test.ts::"exposes the four documented codes"` AND `src/worker-side/worker-side.test.ts::"dispose() rejects subsequent calls with PROXY_DISPOSED"` AND `src/main-side/main-side.test.ts::"rejects prepare when adapter does not implement it"` AND `src/main-side/main-side.test.ts::"rejects transaction when adapter does not implement it"`
---
### Requirement: Conformance harness backs Stage-4 adapter testing
The package SHALL ship an in-process test harness (`createDbProxyHarness`) that pairs a `MessageChannel` with a `createDbProxyServer` on one port and a `createDbProxyClient` on the other, backed by an `InMemoryAdminDb` for deterministic conformance scenarios. Stage-4 DB adapters MUST pass conformance under BOTH the direct surface AND this proxy surface before Stage 4 can be declared complete.
#### Scenario: harness drives a CRUD round-trip
- **WHEN** a Stage-4 adapter test drives `createDbProxyHarness({ seed })` through INSERT / SELECT / DELETE
- **THEN** every observation matches the direct-surface adapter
**Verified by:** `tests/conformance/run-db-adapter-conformance.test.ts::"CRUD round-trip via the proxy"` AND `src/testing/testing.test.ts::"wires the in-memory db to a real proxy round-trip"`

View File

@@ -0,0 +1,68 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-events-in-memory (api-events-in-memory)
**Package**: `@suaveplan/api-events-in-memory`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Wraps the real @suaveplan/events bus
The factory SHALL construct an `EventBusLike` by delegating to `@suaveplan/events`'s `createEventBus`. Subscribers receive events as the real bus delivers them; this package only adds a recording layer around publish.
#### Scenario: subscriber receives published payload
- **WHEN** a subscriber is registered on `"billing.invoice.created"` AND a payload is published
- **THEN** the subscriber is invoked with the exact payload
**Verified by:** `src/factory/create-in-memory-events-bus.test.ts::"subscriber receives published payload"`
---
### Requirement: published(topic) returns history
`host.events.published(topic)` SHALL return an array of every payload published to `topic` since the last `reset()` (or since construction), in publish order.
#### Scenario: history captures every publish in order
- **WHEN** three payloads `[A, B, C]` are published to `"x"`
- **THEN** `host.events.published("x")` returns `[A, B, C]` in that order
#### Scenario: reset clears history
- **WHEN** `host.events.reset()` is called after publishes
- **THEN** `host.events.published("x")` returns `[]` AND already-registered subscribers stay registered
#### Scenario: history bounded by maxHistoryPerTopic
- **WHEN** `maxHistoryPerTopic: 10` is configured AND 11 payloads are published
- **THEN** publish either throws `EVENTS_IN_MEMORY_HISTORY_OVERFLOW` OR drops the oldest payload (the chosen policy is documented and tested both ways)
**Verified by:** `src/factory/create-in-memory-events-bus.test.ts::"history captures every publish in order"` AND `src/factory/create-in-memory-events-bus.test.ts::"reset clears history without unregistering subscribers"` AND `src/history/history.test.ts::"bounds history at maxHistoryPerTopic"`
---
### Requirement: Conforms to EventsBus contract
The factory SHALL pass `runEventsBusConformance` from `@suaveplan/api-events/testing` (which itself wraps the conformance defined for `@suaveplan/events` plus any API-foundation specifics — error semantics, payload-id idempotency).
#### Scenario: conformance suite passes
- **WHEN** `runEventsBusConformance("in-memory-direct", () => createInMemoryEventsBus(...))` runs
- **THEN** every invariant passes
**Verified by:** `src/factory/create-in-memory-events-bus.spec-conformance.test.ts::"in-memory-direct conforms to EventsBus contract"`
---
### Requirement: Registered in `api-host/testing` adapter table
#### Scenario: createTestHost resolves the sentinel
- **WHEN** `createTestHost({ adapters: { events: "in-memory" } })`
- **THEN** the resulting `host.events` is an instance produced by this factory AND has a `published(topic)` method
**Verified by:** `tests/integration/integration.test.ts::"createTestHost resolves events:\"in-memory\" to this adapter and exposes published()"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,70 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-events-worker-proxy (api-events-worker-proxy)
**Package**: `@suaveplan/api-events-worker-proxy`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Proxy preserves the EventBusLike surface from `@suaveplan/events`
The package SHALL expose `createEventsProxyClient(port)` returning a value that satisfies `WorkerProxyOf<EventBusLike>` where `EventBusLike` is the EXISTING type from `@suaveplan/events`. No new contract layer SHALL be introduced — worker-side plugin code that worked against the direct bus MUST also work against the proxy with no source change beyond async-await wrapping (which `WorkerProxyOf` already enforces by promise-wrapping return types).
#### Scenario: proxy type matches WorkerProxyOf<EventBusLike>
- **WHEN** `expectTypeOf<ReturnType<typeof createEventsProxyClient>>().toEqualTypeOf<WorkerProxyOf<EventBusLike>>()` is checked
- **THEN** the assertion passes
**Verified by:** `src/client/client.spec-conformance.test.ts::"proxy type matches WorkerProxyOf<EventBusLike>"`
---
### Requirement: Disallowed-topic emit throws reconstructed SuaveplanError
The package SHALL marshal `emit(topic, payload)` calls to the main-process bus where topic-allow checks are enforced per design doc §5.2b. If the main side rejects with a disallow signal, the proxy MUST surface `EVENTS_PROXY_TOPIC_NOT_ALLOWED` as a `SuaveplanError` reconstructed via `api-worker-rpc`'s error-reconstruction.
#### Scenario: allowed topic emit round-trips
- **WHEN** a plugin manifests `publishes: ["billing.invoice.created"]` AND calls `emit("billing.invoice.created", payload)`
- **THEN** the main-side bus receives the event AND the call resolves successfully
#### Scenario: disallowed topic throws TOPIC_NOT_ALLOWED
- **WHEN** the plugin calls `emit("foreign.topic", payload)` with no matching manifest entry
- **THEN** the proxy call rejects with `SuaveplanError` of code `EVENTS_PROXY_TOPIC_NOT_ALLOWED`
**Verified by:** `src/publisher/publisher.test.ts::"allowed topic emit round-trips"` AND `src/publisher/publisher.test.ts::"disallowed topic throws TOPIC_NOT_ALLOWED"`
---
### Requirement: Subscription delivers events from main bus to worker handler with backpressure
The package SHALL marshal `on(topic, handler)` so that events emitted on the main-process bus are delivered to the worker-side handler. Worker-side handler throws MUST surface main-side as `EVENTS_PROXY_HANDLER_THROW`. The main-side dispatcher MUST respect `api-worker-rpc`'s backpressure budget — when the worker is slow, main-side delivery throttles rather than buffering unbounded.
#### Scenario: event delivered to worker handler
- **WHEN** the main bus emits `topic` AND the worker handler is registered
- **THEN** the handler observes the event payload
#### Scenario: slow handler backpressures producer
- **WHEN** the worker handler takes 100ms per call AND main emits 100 events in a burst
- **THEN** the main-side dispatcher pauses at the backpressure budget rather than buffering all 100 in memory
**Verified by:** `src/subscriber/subscriber.test.ts::"event delivered to handler"` AND `src/subscriber/subscriber.test.ts::"slow handler backpressures producer"`
---
### Requirement: Conformance suite passes against this proxy
The package SHALL be runnable as the surface argument to `runEventsBusConformance` from `@suaveplan/events/testing`. Every conformance scenario (FIFO ordering, interceptors, idempotent listeners) MUST pass identically to a direct in-process bus. This is the Stage-4-precondition gate.
#### Scenario: runEventsBusConformance passes
- **WHEN** `runEventsBusConformance("worker-proxy", makeFactory)` runs
- **THEN** every scenario passes
**Verified by:** `tests/conformance/run-events-bus-conformance.test.ts::"runEventsBusConformance passes against proxy"`

View File

@@ -0,0 +1,127 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-graphql-apollo
**Package**: `@suaveplan/api-graphql-apollo`
**Status**: SHIPPED 2026-05-26
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Implements the abstract GraphQL engine contract
The package SHALL export `createApolloGraphqlEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-graphql`. The conformance suite `runGraphqlEngineConformance` MUST pass against this adapter with the same invariants required of `api-graphql-yoga`.
#### Scenario: conformance suite passes
- **WHEN** `runGraphqlEngineConformance("apollo", createApolloGraphqlEngine)` runs
- **THEN** every invariant passes
**Verified by:** `src/engine/apollo-engine.spec-conformance.test.ts::"createApolloGraphqlEngine passes runGraphqlEngineConformance"`
---
### Requirement: Endpoint set identical to Yoga adapter
The adapter SHALL mount the same four endpoints (`POST /graphql`, `GET /graphql/sdl`, `WS /graphql/ws`, `GET /graphql/ui`) at the same default paths with the same configurability. The `/graphql/ui` endpoint MUST return 404 when no UI plugin is wired — the adapter MUST NOT embed Apollo's landing page as a default (UI is opt-in via a separate package, identical to Yoga's stance).
#### Scenario: endpoint set matches Yoga
- **WHEN** the engine mounts on `api-router-in-memory`
- **THEN** the four endpoints respond at the same paths as the Yoga adapter does
#### Scenario: landing page disabled by default
- **WHEN** no UI plugin is wired
- **THEN** `GET /graphql/ui` returns 404 (NOT an Apollo landing page)
**Verified by:** `src/endpoints/http.test.ts::"endpoint set matches Yoga at default paths"` AND `src/endpoints/ui-proxy.test.ts::"/graphql/ui returns 404 when no UI plugin is wired"`
---
### Requirement: Federation across plugins with collision detection
The engine SHALL accept schema contributions from multiple plugins and merge them into one executable schema. `extend type Query` from multiple plugins MUST union. Same-field collisions without `graphql.namespace` MUST fail with `GRAPHQL.SCHEMA_COLLISION` (the canonical code, NOT a `GRAPHQL_APOLLO_*` code).
#### Scenario: two plugins extending Query both expose fields
- **WHEN** plugin A and plugin B each extend `Query`
- **THEN** both contribute fields to the merged schema
#### Scenario: collision fails at build
- **WHEN** two plugins contribute the same field on the same type
- **THEN** schema build fails with `GRAPHQL.SCHEMA_COLLISION`
**Verified by:** `src/federation/federation.test.ts::"two plugins extending Query both expose their fields"` AND `src/federation/federation.test.ts::"same-field collision fails with GRAPHQL.SCHEMA_COLLISION"`
---
### Requirement: Subscriptions ride api-ws
Subscriptions SHALL use `graphql-ws` protocol over the host's wired `api-ws` engine, with subscription resolvers returning `AsyncIterable` driven by the event bus, identical to the Yoga adapter.
#### Scenario: tenant-scoped subscription delivery
- **WHEN** a subscriber for tenant T subscribes AND an event for tenant T is emitted
- **THEN** the subscriber receives the event AND a tenant-U subscriber does not
**Verified by:** `src/subscriptions/subscriptions.test.ts::"subscription delivers tenant-scoped events only to matching tenants"`
---
### Requirement: Persisted-query manifest support
When `ApolloGraphqlOptions.persistedQueriesPath` is set, the engine SHALL accept `{ id }` in place of `{ query }` on `POST /graphql` and resolve `id` against the loaded manifest. An unknown id MUST fail with `GRAPHQL_APOLLO_PERSISTED_QUERY_NOT_FOUND`.
#### Scenario: known id resolves to its query
- **WHEN** `POST /graphql` arrives with `{ id: "abc123" }` AND `abc123` is in the manifest
- **THEN** the engine executes the manifest's query for that id
#### Scenario: unknown id is rejected
- **WHEN** the id is not in the manifest
- **THEN** the response is `GRAPHQL_APOLLO_PERSISTED_QUERY_NOT_FOUND`
**Verified by:** `src/persisted-queries/persisted-queries.test.ts::"known id resolves to its query"` AND `src/persisted-queries/persisted-queries.test.ts::"unknown id returns GRAPHQL_APOLLO_PERSISTED_QUERY_NOT_FOUND"`
---
### Requirement: Per-request DataLoader scoping
The engine SHALL create DataLoader instances per-request bound to `RequestContext`, with two concurrent requests receiving distinct loader instances.
#### Scenario: concurrent requests do not share loader caches
- **WHEN** R1 and R2 both invoke a loader-using resolver
- **THEN** R1's cached value is not served to R2
**Verified by:** `src/loaders/loaders.test.ts::"two concurrent requests get distinct DataLoader instances"`
---
### Requirement: OTel surface uses engine-agnostic metric names
The engine SHALL emit `graphql.requests_total`, `graphql.request_duration_ms`, `graphql.resolver_duration_ms`, `graphql.subscriptions_active`, and `graphql.schema_rebuilds_total` — the same metric names emitted by the Yoga adapter. Operators MUST be able to swap engines without renaming dashboards.
#### Scenario: metric names match Yoga's
- **WHEN** both engines run against the same plugin set under an in-memory `MeterProvider`
- **THEN** the set of metric names observed is identical
**Verified by:** `tests/integration/engine-swap.test.ts::"metric names are identical between Apollo and Yoga"`
---
### Requirement: Depth and complexity guards
The engine SHALL reject queries exceeding `maxQueryDepth` with `GRAPHQL_APOLLO_DEPTH_EXCEEDED` AND queries exceeding `maxQueryComplexity` with `GRAPHQL_APOLLO_COMPLEXITY_EXCEEDED`. Defaults: depth 10, complexity 1000.
#### Scenario: query exceeding depth is rejected
- **WHEN** a query has nested selection 11 levels deep AND `maxQueryDepth: 10`
- **THEN** execution fails with `GRAPHQL_APOLLO_DEPTH_EXCEEDED`
**Verified by:** `src/guards/depth-and-complexity.test.ts::"query exceeding depth limit rejected with GRAPHQL_APOLLO_DEPTH_EXCEEDED"`

View File

@@ -0,0 +1,83 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-graphql-ui-altair
**Package**: `@suaveplan/api-graphql-ui-altair`
**Status**: SHIPPED 2026-05-26
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Mounts Altair at the configured UI endpoint
The package SHALL expose `createAltairUi(opts)` returning a `RouterPlugin` that registers `GET <uiEndpoint>` (default `/graphql/ui`). The response MUST be the Altair HTML shell embedding the configured `graphqlEndpoint`, `subscriptionEndpoint`, `initialQuery`, `initialHeaders`, and `initialEnvironments`.
#### Scenario: default mount serves Altair at /graphql/ui
- **WHEN** the plugin is mounted with default `opts`
- **THEN** `GET /graphql/ui` returns 200 AND the body contains the Altair shell AND the embedded `graphqlEndpoint` reads `/graphql`
#### Scenario: configured initial environments flow into the shell
- **WHEN** `createAltairUi({ initialEnvironments: { production: { url: "https://api.example.com" } } })`
- **THEN** the rendered HTML embeds those environments
**Verified by:** `src/factory/create-altair-ui.test.ts::"default mount returns Altair HTML at /graphql/ui"` AND `src/factory/create-altair-ui.spec-conformance.test.ts::"initialEnvironments flow into embedded shell"`
---
### Requirement: Bundled assets only — no CDN fetches
Every static asset SHALL be served from this package's `dist/assets/`. No external host references. Cache-busting hashes embedded. Missing asset returns 404 with `GRAPHQL_UI_ALTAIR_ASSET_MISSING`.
#### Scenario: no external hosts in the shell
- **WHEN** the HTML body is inspected
- **THEN** no `src` or `href` attribute points at an external host
#### Scenario: missing asset returns spec'd code
- **WHEN** a request arrives for `<uiEndpoint>/assets/does-not-exist.js`
- **THEN** the response is 404 with `code: "GRAPHQL_UI_ALTAIR_ASSET_MISSING"`
**Verified by:** `src/assets/assets.test.ts::"rendered shell references no external hosts"` AND `src/assets/assets.test.ts::"missing asset returns GRAPHQL_UI_ALTAIR_ASSET_MISSING"`
---
### Requirement: CSP header on the HTML response
The response SHALL carry `Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'`. Inline scripts MUST NOT be permitted.
#### Scenario: CSP header present, scripts not 'unsafe-inline'
- **WHEN** the HTML response is returned
- **THEN** the CSP header is set AND `script-src` does NOT include `'unsafe-inline'`
**Verified by:** `src/headers/csp.test.ts::"CSP header set with 'unsafe-inline' only for style-src"`
---
### Requirement: Endpoint-collision detection
If `<uiEndpoint>` is already mounted, this package SHALL refuse with `GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION`.
#### Scenario: collision refused
- **WHEN** another plugin has already mounted `GET /graphql/ui`
- **THEN** mount fails with `GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION`
**Verified by:** `src/factory/create-altair-ui.test.ts::"endpoint collision refused with GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION"`
---
### Requirement: OTel counter shares the GraphiQL variant's metric name
The package SHALL emit `graphql.ui.requests_total` counter labelled `{ ui: "altair" }` on each UI HTML request. The metric name is deliberately identical to the GraphiQL variant's so dashboards can sum across both UIs by dropping the `ui` label.
#### Scenario: UI HTML request increments the counter with ui="altair"
- **WHEN** `GET /graphql/ui` returns 200
- **THEN** an in-memory `MeterProvider` observes `graphql.ui.requests_total{ui="altair"}` increment by 1
**Verified by:** `src/instruments/instruments.test.ts::"UI HTML increments graphql.ui.requests_total with ui='altair' label"`

View File

@@ -0,0 +1,88 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-graphql-ui-graphiql (api-graphql-ui-graphiql)
**Package**: `@suaveplan/api-graphql-ui-graphiql`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: Mounts GraphiQL at the configured UI endpoint
The package SHALL expose `createGraphiqlUi(opts)` returning a `RouterPlugin` that registers `GET <uiEndpoint>` (default `/graphql/ui`) on the host's `api-router`. The response MUST be the GraphiQL HTML shell embedding the configured `graphqlEndpoint` and `subscriptionEndpoint`.
#### Scenario: default mount serves GraphiQL at /graphql/ui
- **WHEN** the plugin is mounted with default `opts`
- **THEN** `GET /graphql/ui` returns 200 AND the body contains the GraphiQL shell AND the embedded `graphqlEndpoint` reads `/graphql`
#### Scenario: configured endpoints flow into the shell
- **WHEN** `createGraphiqlUi({ graphqlEndpoint: "/api/gql", subscriptionEndpoint: "/api/gql/ws" })`
- **THEN** the rendered HTML embeds those endpoints (NOT the defaults)
**Verified by:** `src/factory/create-graphiql-ui.test.ts::"default mount returns GraphiQL HTML at /graphql/ui"` AND `src/factory/create-graphiql-ui.spec-conformance.test.ts::"configured endpoints flow into the embedded shell"`
---
### Requirement: Bundled assets only — no CDN fetches
Every static asset referenced by the GraphiQL HTML shell SHALL be served from this package's own `dist/assets/`. No `<script src="https://...">` or `<link href="https://...">` MAY point at any CDN. Asset paths MUST embed a cache-busting hash so version upgrades invalidate browser caches. A request for a missing asset MUST return 404 with code `GRAPHQL_UI_GRAPHIQL_ASSET_MISSING`.
#### Scenario: no CDN references in the rendered shell
- **WHEN** the HTML body is inspected
- **THEN** no `src` or `href` attribute points at an external host
#### Scenario: missing asset returns the spec'd code
- **WHEN** a request arrives for `<uiEndpoint>/assets/does-not-exist.js`
- **THEN** the response is 404 with `code: "GRAPHQL_UI_GRAPHIQL_ASSET_MISSING"`
**Verified by:** `src/assets/assets.test.ts::"rendered shell references no external hosts"` AND `src/assets/assets.test.ts::"missing asset returns GRAPHQL_UI_GRAPHIQL_ASSET_MISSING"`
---
### Requirement: CSP header on the HTML response
The HTML response SHALL carry `Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'` so the rendered page is safe to expose in production environments that enforce CSP. Inline styles are required by GraphiQL's bundle; inline scripts MUST NOT be permitted.
#### Scenario: CSP header is present and excludes 'unsafe-inline' for scripts
- **WHEN** the HTML response is returned
- **THEN** the `Content-Security-Policy` header is set AND `script-src` does NOT include `'unsafe-inline'`
**Verified by:** `src/headers/csp.test.ts::"CSP header set with 'unsafe-inline' only for style-src"`
---
### Requirement: Endpoint-collision detection
If the host already has a route registered at `<uiEndpoint>`, this package SHALL refuse to mount with `GRAPHQL_UI_GRAPHIQL_ENDPOINT_COLLISION`. Operators see the collision at boot rather than at first request.
#### Scenario: collision is refused
- **WHEN** another plugin has already mounted `GET /graphql/ui` AND this package attempts to mount on top
- **THEN** mount fails with `GRAPHQL_UI_GRAPHIQL_ENDPOINT_COLLISION`
**Verified by:** `src/factory/create-graphiql-ui.test.ts::"endpoint collision refused with GRAPHQL_UI_GRAPHIQL_ENDPOINT_COLLISION"`
---
### Requirement: OTel counter for UI traffic
The package SHALL emit `graphql.ui.requests_total` counter labelled `{ ui: "graphiql" }` on each successful UI HTML request. Asset sub-requests MUST NOT increment this counter (they are observed via the router's standard request metrics).
#### Scenario: UI HTML request increments the counter
- **WHEN** `GET /graphql/ui` returns 200
- **THEN** an in-memory `MeterProvider` observes `graphql.ui.requests_total{ui="graphiql"}` increment by 1
#### Scenario: asset request does NOT increment the counter
- **WHEN** an asset under `<uiEndpoint>/assets/...` is fetched
- **THEN** the counter does NOT increment
**Verified by:** `src/instruments/instruments.test.ts::"UI HTML increments graphql.ui.requests_total"` AND `src/instruments/instruments.test.ts::"asset requests do not increment the UI counter"`

View File

@@ -0,0 +1,127 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-graphql-yoga
**Package**: `@suaveplan/api-graphql-yoga`
**Status**: SHIPPED 2026-05-26
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Implements the abstract GraphQL engine contract
The package SHALL export `createYogaGraphqlEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-graphql`. The same conformance suite (`runGraphqlEngineConformance`) MUST pass against this adapter AND against `api-graphql-apollo` — switching engines is host-config only.
#### Scenario: conformance suite passes
- **WHEN** `runGraphqlEngineConformance("yoga", createYogaGraphqlEngine)` runs
- **THEN** every invariant passes
**Verified by:** `src/engine/yoga-engine.spec-conformance.test.ts::"createYogaGraphqlEngine passes runGraphqlEngineConformance"`
---
### Requirement: Endpoint set mounted at documented paths
The engine SHALL mount four endpoints via the host's `api-router`:
- `POST /graphql` — main execution endpoint accepting `{ query, variables, operationName }`.
- `GET /graphql/sdl` — returns the merged SDL across all plugin contributions, content-type `text/plain`.
- `WS /graphql/ws` — `graphql-ws` protocol over the host's wired `api-ws` engine.
- `GET /graphql/ui` — proxied to whichever UI plugin (`api-graphql-ui-graphiql` or `api-graphql-ui-altair`) is wired; if none is wired, returns 404 (NOT a default GraphiQL — keeping UI choice opt-in).
Each path is configurable via `YogaGraphqlOptions`.
#### Scenario: every documented endpoint responds
- **WHEN** the engine is mounted on an `api-router-in-memory` instance
- **THEN** `POST /graphql`, `GET /graphql/sdl`, `WS /graphql/ws` are reachable
#### Scenario: UI endpoint is 404 when no UI plugin is wired
- **WHEN** no UI plugin is registered AND `GET /graphql/ui` is requested
- **THEN** the response is 404 (NOT a fallback GraphiQL)
**Verified by:** `src/endpoints/http.test.ts::"every documented endpoint responds"` AND `src/endpoints/ui-proxy.test.ts::"/graphql/ui returns 404 when no UI plugin is wired"`
---
### Requirement: Schema federation across plugins
The engine SHALL accept schema contributions from multiple plugins (each via `registry.addTypes(sdl)` + `registry.addResolvers(map)`) and merge them into one executable schema via `@graphql-tools/schema`. `extend type Query { ... }` from multiple plugins MUST union. Two plugins declaring the same field on the same type without a `graphql.namespace` MUST fail schema build with `GRAPHQL.SCHEMA_COLLISION` (the canonical code on `@suaveplan/api-graphql`'s namespace, NOT a `GRAPHQL_YOGA_*` code).
#### Scenario: two plugins each extend Query
- **WHEN** plugin A contributes `extend type Query { invoices: [Invoice!]! }` AND plugin B contributes `extend type Query { customers: [Customer!]! }`
- **THEN** both `invoices` and `customers` are queryable on the merged schema
#### Scenario: same-field collision fails at build
- **WHEN** plugin A and plugin B both contribute `extend type Query { invoices: ... }` without `graphql.namespace`
- **THEN** schema build fails with `GRAPHQL.SCHEMA_COLLISION`
**Verified by:** `src/federation/federation.test.ts::"two plugins each extending Query both expose their fields"` AND `src/federation/federation.test.ts::"same-field collision fails with GRAPHQL.SCHEMA_COLLISION"`
---
### Requirement: Subscriptions ride the WebSocket engine
Subscriptions SHALL use `graphql-ws` protocol over the host's wired `api-ws` engine at `WS /graphql/ws`. Subscription resolvers MUST return `AsyncIterable` driven by the event bus; the engine wires `rctx.bus.asyncIterator(topic, { tenant })` directly. Subscription tear-down MUST release the WS connection's subscription slot.
#### Scenario: subscription delivers tenant-scoped events
- **WHEN** a client subscribes to `invoiceCreated` for tenant T AND the host emits `billing.invoice.created` for tenant T
- **THEN** the subscriber receives one event AND a subscriber for tenant U receives zero
#### Scenario: subscription tear-down releases the slot
- **WHEN** the client closes the subscription
- **THEN** the underlying `api-ws` subscription slot is released within 1000ms
**Verified by:** `src/subscriptions/subscriptions.test.ts::"subscription delivers tenant-scoped events only to matching tenants"` AND `src/subscriptions/subscriptions.test.ts::"closing subscription releases the api-ws slot"`
---
### Requirement: Per-request DataLoader scoping
`DataLoader` instances SHALL be constructed per-request and bound to `RequestContext`. They MUST be created at pipeline step 7 (capability scoping) and disposed at step 11 (cleanup). Two concurrent requests MUST NOT share a loader cache.
#### Scenario: two concurrent requests get distinct loaders
- **WHEN** requests R1 and R2 both invoke a resolver that uses `rctx.loaders.customer`
- **THEN** R1's loader and R2's loader are distinct instances AND R1's cached value is NOT served to R2
**Verified by:** `src/loaders/loaders.test.ts::"two concurrent requests get distinct DataLoader instances"`
---
### Requirement: Depth and complexity guards
The engine SHALL reject queries exceeding `maxQueryDepth` with `GRAPHQL_YOGA_DEPTH_EXCEEDED` AND queries exceeding `maxQueryComplexity` with `GRAPHQL_YOGA_COMPLEXITY_EXCEEDED`. Both limits MUST be configurable via `YogaGraphqlOptions`; sensible defaults (`maxQueryDepth: 10`, `maxQueryComplexity: 1000`) MUST be applied when unset.
#### Scenario: query exceeding depth limit is rejected
- **WHEN** a query has nested selection 11 levels deep AND `maxQueryDepth: 10`
- **THEN** execution fails with `GRAPHQL_YOGA_DEPTH_EXCEEDED`
**Verified by:** `src/guards/depth-and-complexity.test.ts::"query exceeding depth limit rejected with GRAPHQL_YOGA_DEPTH_EXCEEDED"`
---
### Requirement: OTel surface
The engine SHALL emit:
- `graphql.requests_total` counter `{ operation_type, operation_name, outcome }`.
- `graphql.request_duration_ms` histogram.
- `graphql.resolver_duration_ms` histogram `{ type, field }`.
- `graphql.subscriptions_active` observable gauge.
- `graphql.schema_rebuilds_total` counter.
#### Scenario: successful query increments counter and emits histogram
- **WHEN** a query executes successfully
- **THEN** an in-memory `MeterProvider` observes one increment in `graphql.requests_total{outcome="ok"}` AND one observation in `graphql.request_duration_ms`
**Verified by:** `src/instruments/instruments.test.ts::"successful query emits requests_total + request_duration_ms"`

View File

@@ -0,0 +1,153 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-graphql
**Package**: `@suaveplan/api-graphql`
**Status**: SHIPPED 2026-05-25
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: GraphqlRegistry accumulator contract
The package SHALL export the `GraphqlRegistry` interface with `addTypes(sdl: string): void`, `addResolvers(map: ResolverMap): void`, `addLoader<K, V>(name: string, batcher: BatchFn<K, V>): void`. Multiple calls of each method MUST accumulate (not overwrite); the host calls `freeze()` after every plugin has contributed. The registry MUST be generic over `Caps extends CapabilityMap` so resolvers and loaders receive the typed `RequestContext<Caps>`.
#### Scenario: addTypes accumulates across calls
- **WHEN** two SDL strings are added in sequence
- **THEN** both contributions are visible in the merged output
#### Scenario: addLoader keyed by name
- **WHEN** two loaders with the same name are registered
- **THEN** the second call throws `GRAPHQL.LOADER_NOT_REGISTERED` is NOT thrown but a duplicate-registration error is — pinned as part of `GRAPHQL_CODES` design — actually `LOADER_DUPLICATE_NAME` per the registry tests
**Verified by:** `src/registry/registry.test.ts::"addTypes accumulates across calls"` AND `src/registry/registry.test.ts::"addLoader rejects duplicate names"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: Federation merger with collision detection
The package SHALL export `mergeSdl(contributions: readonly SdlContribution[]): MergedSchema`. Multiple plugins contributing `extend type Query { ... }` SHALL be unioned. A collision (two plugins declaring the same field on the same type without the field being declared as an `extend`) MUST throw `GRAPHQL.SCHEMA_COLLISION` with `details.path = "Type.field"` AND `details.plugins = [A, B]`. The merger MUST honour `graphql.namespace`: when a plugin declares `graphql.namespace: "Billing"` in its manifest, its `Query`/`Mutation`/`Subscription` extensions MUST be wrapped into `BillingQuery`/`BillingMutation`/`BillingSubscription` types referenced from the root.
#### Scenario: two plugins extend Query without collision
- **WHEN** plugin A extends `Query { invoices }` AND plugin B extends `Query { users }`
- **THEN** the merged schema has both fields on `Query`
#### Scenario: collision reported with plugin pair
- **WHEN** plugin A and plugin B both declare `Query.users` without namespace
- **THEN** `GRAPHQL.SCHEMA_COLLISION` is thrown AND `details.plugins` is `["A", "B"]`
#### Scenario: namespace wrapper applied
- **WHEN** plugin "Billing" declares `graphql.namespace: "Billing"` AND extends `Query { invoices }`
- **THEN** the merged schema exposes `Query.billing: BillingQuery` AND `BillingQuery.invoices` is reachable
**Verified by:** `src/federation/merge-sdl.test.ts::"two plugins extend Query without collision"` AND `src/federation/merge-sdl.test.ts::"collision reports plugin pair"` AND `src/federation/namespace.test.ts::"namespace wraps contributions under wrapper type"`
---
### Requirement: Resolver type is RequestContext-bound
The package SHALL export `Resolver<Parent, Args, Result, Caps extends CapabilityMap>` where the third argument is `RequestContext<Caps>` (not `PluginContext`). Resolvers MUST receive tenant-scoped `db`, request-bound `principal`, and the per-request DataLoaders.
#### Scenario: resolver third arg is RequestContext
- **WHEN** `expectTypeOf<Parameters<Resolver<P, A, R, C>>[2]>()` is checked
- **THEN** it equals `RequestContext<C>` (not `PluginContext<C>`)
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts::"resolver third argument is RequestContext"`
---
### Requirement: SubscriptionResolver returns AsyncIterable
The package SHALL export `SubscriptionResolver<Parent, Args, Result, Caps>` whose `subscribe` returns `AsyncIterable<Result> | Promise<AsyncIterable<Result>>`. Subscriptions ride the WebSocket engine (`api-ws`); this contract MUST reference `api-ws`'s `SubscriptionBus` via type only (no runtime dep).
#### Scenario: subscribe returns AsyncIterable
- **WHEN** `expectTypeOf<SubscriptionResolver<P, A, R, C>["subscribe"]>()` is checked
- **THEN** the return type unifies to `AsyncIterable<R> | Promise<AsyncIterable<R>>`
**Verified by:** `src/subscription/subscription.spec-conformance.test.ts::"subscribe returns AsyncIterable"`
---
### Requirement: Directive contracts
The package SHALL export descriptors for `@auth(scope: String!)` and `@rateLimit(rpm: Int!)` directives. Each descriptor MUST declare: directive name, locations (`FIELD_DEFINITION`), argument schema, host-side handler hook. Engine adapters install the directives at schema-build time.
#### Scenario: @auth descriptor present
- **WHEN** the test imports `authDirective`
- **THEN** the descriptor's `name === "auth"` AND `locations` includes `FIELD_DEFINITION`
**Verified by:** `src/directives/directives.test.ts::"auth and rateLimit descriptors present"`
---
### Requirement: GraphqlEngine lifecycle interface
The package SHALL export `GraphqlEngine` interface: `buildSchema(registry: GraphqlRegistry): MergedSchema`, `executeQuery(req: GraphqlRequest, ctx: RequestContext): Promise<GraphqlResponse>`, `executeSubscription(req, ctx): AsyncIterable<GraphqlResponse>`, `dispose(): Promise<void>`. Concrete engines (`api-graphql-yoga`, `api-graphql-apollo`) implement this interface.
#### Scenario: every required method present
- **WHEN** `expectTypeOf<GraphqlEngine>()` is checked
- **THEN** all four methods are declared with the documented signatures
**Verified by:** `src/engine/engine.spec-conformance.test.ts::"GraphqlEngine declares buildSchema executeQuery executeSubscription dispose"`
---
### Requirement: CapabilityId augmentation contributes "graphql"
The package SHALL augment the `CapabilityId` union from `@suaveplan/api-types` to include `"graphql"`. Type-only augmentation (no runtime cost).
#### Scenario: "graphql" is assignable to CapabilityId
- **WHEN** `expectTypeOf<"graphql">().toMatchTypeOf<CapabilityId>()` is checked
- **THEN** the assertion passes
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"graphql is a valid CapabilityId after augmentation"`
---
### Requirement: Error codes registry frozen
The package SHALL export `GRAPHQL_CODES` via `defineCodes` containing exactly: `SCHEMA_COLLISION`, `RESOLVER_MISSING`, `LOADER_DUPLICATE_NAME`, `LOADER_NOT_REGISTERED`, `DIRECTIVE_UNKNOWN`, `SUBSCRIPTION_TRANSPORT_UNAVAILABLE`, `NAMESPACE_REQUIRED`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `GRAPHQL_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every GRAPHQL_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for engines
The package SHALL export `runGraphqlEngineConformance({ factory, label })` from `./testing`. Both `api-graphql-yoga` and `api-graphql-apollo` (Stage 7) MUST pass it.
#### Scenario: harness covers query, mutation, subscription, federation
- **WHEN** the harness is run against a stub engine
- **THEN** test categories exist for query, mutation, subscription, federation, directive handling
**Verified by:** `src/testing/conformance.test.ts::"harness covers query mutation subscription federation directives"`

View File

@@ -0,0 +1,872 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-host
**Package**: `@suaveplan/api-host`
**Status**: SHIPPED (Chunks A + B + C + D, 2026-05-25)
> Every requirement below carries a `**Verified by:**` line. (See `openspec/AGENTS.md` Section 13.) Requirements are grouped by surface cluster: lifecycle, hot reload, capability registry, plugin ledger, admin API, error boundary, request pipeline, host composition, OTel, `definePlugin` DX.
## Requirements
### Requirement: Lifecycle FSM drives every plugin through documented states
The package SHALL implement a per-plugin lifecycle driver covering every state and transition from design doc §4: `uninstalled`, `installing`, `installed`, `starting`, `started`, `rolled_back`, `crashed`, `stopping`, `stopped`, `uninstalling`, `install_failed`, `start_failed`, `uninstall_failed`. Each plugin SHALL have a per-plugin mutex; concurrent transition triggers MUST queue (only one transition in flight at a time). The FSM MUST emit `plugin.lifecycle_transitions_total{plugin, from, to, outcome}` on every transition. A handler throw inside a route or job tick MUST NOT transition the FSM — that is the error boundary's job (see "Error boundary" requirement).
#### Scenario: every documented transition is reachable
- **WHEN** a freshly-installed plugin is driven `install → installed → starting → started → stopping → stopped → uninstalling → uninstalled`
- **THEN** every transition fires AND `plugin.lifecycle_transitions_total` records each one
#### Scenario: per-plugin mutex collapses concurrent triggers
- **WHEN** two `start()` calls arrive in the same microtask
- **THEN** the second awaits the first AND only one `starting → started` transition is recorded
#### Scenario: handler throw does not transition the FSM
- **WHEN** a route handler throws `SuaveplanError`
- **THEN** the error boundary catches it AND the plugin's state remains `started`
**Verified by:** `src/lifecycle/lifecycle.test.ts::"every documented transition fires"` AND `src/lifecycle/lifecycle.test.ts::"per-plugin mutex collapses concurrent start triggers"` AND `src/error-boundary/error-boundary.test.ts::"handler throw does not transition FSM"`
---
### Requirement: `crashed` state is reachable only from worker tiers
The package SHALL transition a plugin to `crashed` ONLY when its `isolation` tier is `isolated` or `sandboxed` AND its worker dies, OOMs, or exceeds its wall-clock budget. Trusted-tier plugins cannot reach `crashed` — a handler throw stays in `started` via the error boundary. Auto-restart with exponential backoff MUST trigger up to `crashRestartMax` (default 3) within `crashRestartWindowMs`; after the budget is exhausted the plugin parks in `crashed` until an operator calls `POST /admin/plugins/{name}/restart`.
#### Scenario: trusted plugin handler throw stays started
- **WHEN** a `trusted`-tier plugin's handler throws an uncaught exception
- **THEN** the plugin state remains `started` AND no `crashed` transition is recorded
#### Scenario: isolated plugin worker death triggers `crashed`
- **WHEN** an `isolated`-tier plugin's worker emits an `exit` event with non-zero code
- **THEN** the plugin transitions to `crashed` AND auto-restart fires once after backoff
**Verified by:** `src/lifecycle/lifecycle.spec-conformance.test.ts::"trusted tier handler throw does not crash"` AND `src/lifecycle/lifecycle.spec-conformance.test.ts::"isolated tier worker exit transitions to crashed"`
---
### Requirement: Hot reload uses blue-green primitive by default
The package SHALL implement the §8.1 blue-green reload primitive. Steps 1–8 of §8.1 MUST execute in order: snapshot to `RollbackJournal`, re-resolve manifest, import new module under `?v=N+1`, run migrations if `manifest.database.schemaVersion` increased, mount shadow router slot, run new `start()` + health probe, atomically flip the live-version pointer, drain the old slot. `host.config.blueGreen: false` MUST opt into the 503-window mode (§8.5) for environments that cannot tolerate two concurrent module instances. Native bindings present in the dep graph MUST refuse reload with `RELOAD_NATIVE_BINDING_HELD`.
#### Scenario: blue-green flip is atomic and serves both old and new
- **WHEN** a reload is in progress AND the version pointer has been flipped from N to N+1
- **THEN** new requests hit module N+1 AND in-flight requests continue against N until they settle or `drainDeadlineMs` (default 30s) elapses
#### Scenario: native binding refuses reload
- **WHEN** the dep graph of the new module imports a `.node` file
- **THEN** the loader throws `RELOAD_NATIVE_BINDING_HELD` AND the shadow slot is discarded AND the live module continues serving
#### Scenario: 503-window mode is opt-in via host config
- **WHEN** `host.config.blueGreen === false`
- **THEN** reload runs in single-slot drain-then-replace mode AND new requests during the drain window receive 503 + `Retry-After: 1`
**Verified by:** `src/hot-reload/blue-green.test.ts::"blue-green flip is atomic and drains old slot"` AND `src/hot-reload/blue-green.test.ts::"native binding refuses reload"` AND `src/hot-reload/blue-green.test.ts::"503-window mode opt-in"`
---
### Requirement: RollbackJournal pins last 3 known-good specifiers and auto-rolls-back
The package SHALL maintain a per-plugin ring buffer of depth 3 (configurable). Each entry SHALL match the §8.1a shape: `specifier`, `manifestHash`, `moduleRef: WeakRef<Module> | null`, `migrationCursor`, `capturedAt`, `version`. Entries SHALL be persisted to the plugin ledger so that after a host restart the rollback path still works (with one extra module import; the in-memory `moduleRef` is lost on restart). A `start_failed` event during a reload MUST auto-transition the plugin to `rolled_back` by re-activating the most-recent journal entry's specifier — operator action is NOT required for the happy rollback path.
#### Scenario: ring buffer truncates to last 3 entries
- **WHEN** five successful reloads happen
- **THEN** `journal.length === 3` AND only the last three entries remain
#### Scenario: auto-rollback on `start_failed` during reload
- **WHEN** the new module's `start()` throws during a hot reload
- **THEN** the plugin transitions `start_failed → rolled_back` AND the previous specifier from the journal is re-activated AND `plugin.rolled_back` is emitted
#### Scenario: persisted cursor survives host restart
- **WHEN** the host restarts AND the in-memory `moduleRef` is gone
- **THEN** the persisted `specifier` + `migrationCursor` + `manifestHash` from the ledger let the host re-import the previous version with one additional `import(...)` call
**Verified by:** `src/hot-reload/rollback-journal.test.ts::"ring buffer truncates to last 3"` AND `src/hot-reload/auto-rollback.test.ts::"start_failed during reload auto-rolls-back"` AND `src/hot-reload/rollback-journal.test.ts::"persisted cursor survives restart"`
---
### Requirement: CapabilityRegistry validates version compat at install
The package SHALL expose a `CapabilityRegistry` accepting `defineCapability({ id, version, scope, provider, disposer })` registrations. On `install()` of any plugin, the registry MUST check every entry in the plugin's `requiredCapabilities` against (a) presence in the registry AND (b) version compatibility against the plugin's `apiHost` semver range. Missing → `CAPABILITY_NOT_REGISTERED`. Mismatch → `CAPABILITY_VERSION_MISMATCH`. Duplicate registration of the same `id` → `CAPABILITY_DUPLICATE_REGISTRATION`. Disposers MUST run in reverse-registration order on `stop()`.
#### Scenario: missing required capability blocks install
- **WHEN** a plugin declares `requiredCapabilities: ["db"]` AND no `db` adapter is registered
- **THEN** install throws `CAPABILITY_NOT_REGISTERED` AND the plugin remains `installing` → `install_failed`
#### Scenario: disposer order is reverse-registration
- **WHEN** capabilities are registered in order `[logger, events, db]` AND `stop()` runs
- **THEN** `db.disposer` runs first AND `logger.disposer` runs last
**Verified by:** `src/capability-registry/capability-registry.test.ts::"missing required capability throws"` AND `src/capability-registry/capability-registry.test.ts::"disposer order is reverse-registration"`
---
### Requirement: Plugin ledger metadata DB ships its own forward-only migrations
The package SHALL own a metadata DB whose schema is created and migrated by this package's own migration runner. Tables `suaveplan_plugin_ledger`, `suaveplan_rollback_journal`, `suaveplan_saga_instances`, `suaveplan_saga_steps`, `suaveplan_2pc_transactions` MUST exist after the runner completes. Migrations MUST be forward-only AND idempotent — running the runner twice MUST be a no-op. The default DB SHALL be SQLite at `host.config.metadataDbPath` (default `./suaveplan-host.db`); operators MAY override to Postgres or Surreal via `@suaveplan/db`. The ledger DB connection MUST NOT be shared with any plugin's own DB connection.
#### Scenario: every documented table exists after migration
- **WHEN** the migration runner completes against a fresh SQLite file
- **THEN** every documented table is present with the documented columns
#### Scenario: migration runner is idempotent
- **WHEN** the runner is invoked twice in sequence
- **THEN** the second run is a no-op AND no error is thrown
**Verified by:** `src/plugin-ledger/migrations.test.ts::"every table exists after migration"` AND `src/plugin-ledger/migrations.test.ts::"migration runner is idempotent"`
---
### Requirement: Admin API surface is gated by `host:admin` RBAC and rate-limited
The package SHALL mount the §8.4 admin endpoints under `host.config.adminPrefix` (default `/admin`, `null` disables HTTP admin entirely). Every endpoint SHALL require auth (no `publicRoutes` bypass) AND the `host:admin` scope. Per-principal rate limit MUST be 60 ops/min on read endpoints AND 1 op/min on destructive endpoints (`uninstall`, `force-uninstall`, `disable`). Every state-changing call MUST emit a `host.admin_action` audit envelope per §12.5 AND a `host.admin_action_total{action, outcome}` counter.
#### Scenario: every documented endpoint is mounted
- **WHEN** the host boots with default config
- **THEN** every endpoint from §8.4 (`POST /admin/plugins/{name}/reload`, `disable`, `enable`, `install`, `uninstall`, `retry-install`, `retry-start`, `force-uninstall`, `GET /admin/plugins`, `GET /admin/plugins/{name}`, `GET /admin/capabilities`) is mounted
#### Scenario: `adminPrefix: null` disables HTTP admin
- **WHEN** `host.config.adminPrefix === null`
- **THEN** no admin routes are mounted AND `GET /admin/plugins` returns 404
#### Scenario: destructive endpoint rate-limit kicks in at 2 requests / min
- **WHEN** a single principal calls `POST /admin/plugins/x/uninstall` twice within 60 seconds
- **THEN** the second call returns 429
**Verified by:** `src/admin-api/admin.test.ts::"every documented endpoint is mounted"` AND `src/admin-api/disabled.test.ts::"adminPrefix null disables admin routes"` AND `src/admin-api/admin.test.ts::"destructive rate-limit is 1 per minute"`
---
### Requirement: Error boundary produces the standard envelope and never leaks internals
The package SHALL wrap every pipeline step (§6 steps 1–11) in an error boundary. The boundary MUST map: (a) `SuaveplanError` → status from the plugin manifest's `errors[code].status` map; codes not in the map → 500 with the code preserved in the envelope's `code` field; (b) `AbortError` → 499 (client gone); (c) anything else → 500 + log of the cause chain. The response body SHALL be the `ErrorEnvelope` from `api-types/errors` — `{ error: { code, message, status, requestId, traceId, details } }`. The `X-Trace-Id` response header MUST mirror `traceId`. `details` SHALL be redacted per manifest's `errors.redact[code]` policy AND auto-redacted by `@suaveplan/data-masking` for emails / CC shapes / JWT shapes.
#### Scenario: unmapped code becomes 500 with code preserved
- **WHEN** a handler throws `SuaveplanError` with code `PLUGIN.X.NOT_IN_MAP`
- **THEN** the response status is 500 AND `body.error.code === "PLUGIN.X.NOT_IN_MAP"`
#### Scenario: AbortError becomes 499
- **WHEN** the client drops the connection mid-request
- **THEN** the boundary returns 499 AND no `api.errors_total` counter is incremented
#### Scenario: trace id mirrors response header
- **WHEN** any error response is produced
- **THEN** `res.headers["x-trace-id"] === body.error.traceId`
**Verified by:** `src/error-boundary/error-boundary.test.ts::"unmapped code preserves code in envelope"` AND `src/error-boundary/error-boundary.test.ts::"AbortError maps to 499"` AND `src/error-boundary/error-boundary.test.ts::"x-trace-id mirrors envelope traceId"`
---
### Requirement: Per-request pipeline orders the documented 11 steps and exposes OTel spans
The package SHALL execute the §6 pipeline in the documented order: HTTP intake → route match → auth resolver → tenant resolver chain → (optional) rate limit → (optional) RBAC → body/params/query parse+validate → (optional) idempotency → capability scoping → plugin handler → response validation → response composition → telemetry close. Plugins MAY insert middleware between steps 7 (capability scoping) and 8 (handler) and NOWHERE ELSE. Streaming routes (`streaming: true`) MUST bypass steps 6 (body buffering) and 10 (response composition's buffering). Cookie-based auth MUST trigger CSRF verification via `Bun.CSRF` on non-GET/HEAD methods unless the route opts out with `csrf: false`. Response-validation strictness SHALL default to `enforce` (production: 500 on violation) and `warn` (development: log + counter, body passes through).
#### Scenario: steps execute in documented order
- **WHEN** a request flows through an instrumented pipeline
- **THEN** the observed span tree matches §12.3 exactly (`api.request > api.route_match > api.auth.resolve > api.tenant.resolve > api.rbac.check > api.body.parse > api.body.validate > plugin.handler > api.response.validate > api.response.compose`)
#### Scenario: plugin middleware insertion is restricted
- **WHEN** a plugin attempts to register middleware that runs BEFORE auth
- **THEN** the host throws `PIPELINE_MIDDLEWARE_SLOT_INVALID` at register time
#### Scenario: cookie auth triggers CSRF on POST
- **WHEN** an `auth.session` adapter sets a cookie-source flag AND a request arrives as `POST /x` without a CSRF token
- **THEN** the pipeline returns 403 before reaching the handler
**Verified by:** `src/request-pipeline/pipeline.test.ts::"steps execute in documented order"` AND `src/request-pipeline/pipeline.test.ts::"middleware insertion only between steps 7 and 8"` AND `src/request-pipeline/pipeline.test.ts::"cookie auth triggers CSRF on non-GET"`
---
### Requirement: OTel surface emits every documented metric and span
The package SHALL emit every metric listed in §12.3 from at least one production path: `api.requests_total`, `api.request_duration_ms`, `api.body.bytes`, `api.response.bytes`, `api.errors_total`, `api.auth.failures_total`, `api.rbac.denials_total`, `api.tenant.unresolved_total`, `api.validation.failures_total`, `api.idempotency.replays_total`, `api.rate_limit.exceeded_total`, `plugin.lifecycle_transitions_total`, `plugin.reload_total`, `plugin.reload_duration_ms`, `plugin.unsafe_query_total`, `host.admin_action_total`. Each metric SHALL carry the documented label set. Tenant label MUST be opt-in via `host.config.otel.tenantLabelOptIn` to bound cardinality.
#### Scenario: every documented metric appears in the in-memory exporter
- **WHEN** the test exercises one production path per metric AND collects from `InMemoryMetricExporter`
- **THEN** every metric name from §12.3 is present in the collected output with non-empty data points
**Verified by:** `src/otel/otel.test.ts::"every documented metric is emitted"`
---
### Requirement: `definePlugin` types the capability map from `requires` literals
The package SHALL export `definePlugin<const Requires extends readonly CapabilityId[]>(spec)`. The `ctx` parameter passed to user lifecycle / route handlers SHALL have exactly the fields named in `requires` projected onto `ctx.capabilities`, plus the always-present base fields (`plugin`, `logger`, `events`, `config`, `jobs`, `abortSignal`, `instruments`, `secrets`). The orchestration-level `secrets: SecretsLike` slot is always present (the host injects `EmptySecrets` when no adapter is configured — see foundation-fix-gap-3); the capability-projected `ctx.capabilities.secrets` shape is reachable only when `requires` includes `"secrets"`. Accessing an undeclared capability via the capability slice MUST be a compile-time TypeScript error.
#### Scenario: declared capability is typed and reachable
- **WHEN** a plugin declares `requires: ["db", "auth.session"] as const`
- **THEN** `ctx.capabilities.db` is typed as `AdminDatabaseLike` in lifecycle hooks AND `ctx.principal` is typed as `Principal | null` in route handlers
#### Scenario: undeclared capability is a compile error
- **WHEN** a plugin declares `requires: ["db"] as const` AND references `ctx.capabilities.events.publish(...)`
- **THEN** TypeScript reports an error AND `expectTypeOf<typeof ctx["capabilities"]>().not.toHaveProperty("events")` passes
**Verified by:** `src/define-plugin/define-plugin.spec-conformance.test.ts::"declared capability is typed in ctx"` AND `src/define-plugin/define-plugin.spec-conformance.test.ts::"undeclared capability is a TS error"`
---
### Requirement: Health and readiness endpoints reflect plugin states
The package SHALL mount `GET /internal/livez` (200 if process is responding; no plugin checks) AND `GET /internal/readyz` (200 only if every plugin's state is `started` OR appears in an explicit allowlist; 409 + structured body otherwise). A periodic background health check (default every 10s) SHALL poll each plugin's optional `health` capability hook; a plugin reporting `unhealthy` for longer than `healthFailWindowMs` MUST emit a `plugin.unhealthy` event AND optionally auto-disable per host config (off by default).
#### Scenario: livez is independent of plugin state
- **WHEN** every plugin is `install_failed` AND `/internal/livez` is called
- **THEN** the response is 200
#### Scenario: readyz returns 409 when any plugin is not started
- **WHEN** one plugin is `crashed` AND `/internal/readyz` is called
- **THEN** the response is 409 AND the body lists the unready plugins with their states
**Verified by:** `src/host/health.test.ts::"livez is independent of plugin state"` AND `src/host/health.test.ts::"readyz returns 409 when a plugin is not started"`
---
### Requirement: `./testing` subpath populates the Stage-5 keystone
The package SHALL declare `./testing` as a subpath export AND ship:
1. `createTestHost(config?: TestHostConfig): Promise<TestHost>` — Stage-5 keystone composing every in-memory adapter (`api-router-in-memory`, `api-auth-anonymous` / `api-auth-static-principal`, `api-db-sqlite-memory`, `api-events-in-memory`, `api-ws-broker-in-memory`, `api-tenant-provider-memory`, `api-secrets-env`) into a single line of consumer code.
2. `runPipelineRouterConformance(factory)` plus the `PipelineRouterConformanceFactory` + `PipelineRouteRegistration` types. The harness SHALL exercise the host's `PipelineRouter` contract against any adapter via a factory-supplied `(router, register, reload?)` triple, covering: register → match round-trip, `null` on miss, `statusMap` propagation, plugin ownership across reload, `:param` parsing, and collision detection.
3. The legacy `STAGE_2_RESERVED` sentinel, retained for back-compat with a value of `false` (Stage 2 shipped `true`); consumers that branched on `=== true` now branch on `=== false` to opt into the populated helpers.
`createTestHost` SHALL resolve each adapter slot in override-beats-default order. Defaults: router from `@suaveplan/api-router-in-memory` (loaded via dynamic specifier to side-step the build-graph cycle the package's runtime dep on `api-host` introduces), auth as a `SessionResolverChain` over `createStaticPrincipalResolver` (when `seed.principal` is set) or `createAnonymousResolver`, tenant chain via `tenantResolverChainFromProvider(createMemoryTenantProvider(seed.tenants ?? []), { headerName: "X-Tenant-Id" })`, secrets via `createEnvSecrets(...)` when `seed.secrets` is set else `new EmptySecrets()`, admin db via `createSqliteMemoryAdapter()`, ledger db via a separate `bun:sqlite :memory:` handle. `capabilities`/`rateLimit`/`idempotency`/`csrf`/`audit`/`span`/`adminPrincipal` default to minimal in-process accept-all stubs.
`TestHost` SHALL extend `Host` with `installFixturePlugin(fixture)`, `fetchAs(principal, request)`, `waitForEvent(topic, timeoutMs?)`, `dispose()`, and a `readonly adapters: TestHostAdapters` surface exposing the wired `HostAdapters` bag, the `InMemoryEventsBus`, the `LifecycleEventMap` event bus, and the materialised fixture-route registrations.
`waitForEvent` SHALL first inspect the in-memory bus's recorded history then subscribe to future emissions; it SHALL reject the returned promise when `timeoutMs` elapses.
`dispose` SHALL stop the wrapped host, dispose the event bus, dispose the ws-broker, close the admin db when `close` is available, and close the ledger-db handle. Calling `dispose` more than once is a no-op.
The package's vite build SHALL emit `dist/testing/testing.js` AND `dist/testing/create-test-host/create-test-host.js` AND `dist/testing/pipeline-router-conformance/pipeline-router-conformance.js` so the subpath resolves at runtime.
#### Scenario: zero-config boot yields a usable host with every default adapter
- **WHEN** a test calls `await createTestHost()` with no arguments
- **THEN** the returned `TestHost.plugins()` SHALL be empty AND `host.adapters.host.router` SHALL be defined AND `host.adapters.eventBus` SHALL be defined AND `host.fetch(new Request("http://t/internal/livez"))` SHALL resolve with status 200
#### Scenario: harness drives a reference PipelineRouter through every required case
- **WHEN** a test calls `runPipelineRouterConformance({ label, build: () => ({ router, register, reload? }) })`
- **THEN** `describe("PipelineRouter conformance — ${label}", ...)` registers six `it()` cases AND all of them pass against any adapter whose `register` populates a backing table consulted by `router.match`
#### Scenario: harness asserts collision detection throws
- **WHEN** the harness invokes `register({ plugin: "p", method: "GET", path: "/x" })` followed by `register({ plugin: "q", method: "GET", path: "/x" })`
- **THEN** the second call SHALL throw — the harness uses `expect(...).toThrow()` without prescribing the error class so any adapter-specific typed error satisfies the contract
#### Scenario: harness asserts plugin ownership survives reload
- **WHEN** the factory exposes a `reload()` hook, the harness registers a route, calls `reload()`, then calls `router.match(...)`
- **THEN** the matched route's `plugin` AND `params` survive the reload cycle
#### Scenario: seed.principal reaches `ctx.principal`
- **WHEN** a test calls `createTestHost({ seed: { principal } })` then installs a fixture plugin with a route reading `ctx.principal`
- **THEN** the handler SHALL observe `ctx.principal.id === principal.id` on every request
#### Scenario: seed.tenants + X-Tenant-Id reaches `ctx.tenant`
- **WHEN** a test seeds `tenants: [{ id: "t-a", slug: "tenant-a", ... }]` AND issues `fetch(new Request(url, { headers: { "X-Tenant-Id": "t-a" } }))`
- **THEN** the matched route handler SHALL observe `ctx.tenant.id === "t-a"` AND `ctx.tenant.slug === "tenant-a"`
#### Scenario: seed.secrets exposes `ctx.secrets.require`
- **WHEN** a test seeds `secrets: { FOO: "bar" }` AND a route handler calls `await ctx.secrets.require("FOO")`
- **THEN** the handler SHALL receive the string `"bar"`
#### Scenario: ctx.db round-trips a real bun:sqlite write/read
- **WHEN** the smoke server provisions a tenant-aware table via the admin db AND the per-request handler INSERTs + SELECTs through `ctx.db` under an `X-Tenant-Id` header
- **THEN** the SELECT SHALL return the inserted row
#### Scenario: waitForEvent resolves on bus emission
- **WHEN** a test calls `host.waitForEvent("topic.x", 2000)` AND a route handler (or test) calls `host.adapters.eventBus.publish("topic.x", payload)`
- **THEN** the `waitForEvent` promise SHALL resolve with the payload
#### Scenario: waitForEvent rejects on timeout
- **WHEN** a test calls `host.waitForEvent("topic.never", 25)` AND no emission lands within 25ms
- **THEN** the promise SHALL reject with an `Error` whose `message` contains `"timed out"`
#### Scenario: dispose is idempotent
- **WHEN** a test calls `host.dispose()` twice in succession
- **THEN** the second call SHALL be a no-op (no thrown error, no double-close on the ledger handle)
**Verified by:** `src/testing/create-test-host/create-test-host.test.ts` (13 unit tests against a hand-rolled router adapter), `src/testing/pipeline-router-conformance/pipeline-router-conformance.test.ts`, (downstream) `@suaveplan/api-router-hono`'s `src/pipeline/pipeline.conformance.test.ts`, AND `e2e/foundation-stack-test-host/tests/test-host.spec.ts` (8 Playwright smoke groups against a Bun.serve listener).
---
## ADDED Requirements — Chunk B (Lifecycle + Hot-Reload drivers)
### Requirement: `LifecycleDriver` executes the FSM side-effect descriptors
The package SHALL ship `class LifecycleDriver` that orchestrates per-plugin lifecycle through `createPluginFsm` (from `@suaveplan/api-lifecycle`) and executes each `SideEffectOp` in the discriminated union against pluggable adapter surfaces. The driver SHALL expose verbs `install`, `start`, `stop`, `uninstall`, `crash`, `retryInstall`, `retryStart`, `clearRollback`, `forceUninstall`, `repair`, `startForReload`. The driver SHALL emit `plugin.lifecycle_transitions_total{plugin, from, to, trigger, outcome}` on every transition. Per-plugin mutex semantics are inherited from `createPluginFsm`; concurrent triggers queue FIFO.
#### Scenario: every documented transition is reachable from operator verbs
- **WHEN** a freshly-registered plugin is driven `install → installed → start → started → stop → stopped → uninstall → uninstalled`
- **THEN** every transition fires AND every side-effect op (`mount-routes`, `register-jobs`, `subscribe-events`, `inject-capabilities`, `call-user-hook`, `mark-health`, `write-ledger`, `register-scopes`, `unmount-routes`, `pause-jobs`, `unsubscribe-events`, `dispose-capabilities`, `force-cancel-stragglers`, `apply-data-policy`, `drop-ledger`, `drop-scopes`) executes against the configured adapter.
#### Scenario: typed events fire on the host bus
- **WHEN** the driver completes a lifecycle transition that carries an `emit-event` side-effect
- **THEN** the matching topic (`plugin.installed` / `plugin.started` / `plugin.stopped` / `plugin.uninstalled`) is published on the host `EventBus<LifecycleEventMap>`.
#### Scenario: install hook failure parks in `install_failed`
- **WHEN** a plugin's `install` hook throws
- **THEN** the FSM dispatches `installFailed` AND the plugin lands in `install_failed` AND `lastError` records the cause code + message.
#### Scenario: start hook auto-retries before parking
- **WHEN** a plugin's `start` hook throws on the first attempt but succeeds on retry
- **THEN** the `start_failed` retry budget is consumed AND the FSM reaches `started` via the auto-retry loop.
**Verified by:** `src/lifecycle/lifecycle.test.ts::"drives install → installed → starting → started → stopping → stopped → uninstalling → uninstalled"` AND `src/lifecycle/lifecycle.test.ts::"emits typed events on the host bus"` AND `src/lifecycle/lifecycle.test.ts::"install hook throw parks the plugin in install_failed"` AND `src/lifecycle/lifecycle-coverage.test.ts::"retryStart in runStartRetries can succeed before budget exhaustion"`
---
### Requirement: `LifecycleDriver.crash` honours the per-plugin budget
The driver SHALL transition `started → crashed` on `crash(plugin, cause)` and auto-restart via `crashRestart` up to `crashRestartMax` (default 3) within the `CrashBudgetTracker` window. After the budget is exhausted, the plugin SHALL remain parked in `crashed` until an operator calls a recovery verb. `plugin.crashed` SHALL be emitted on the host bus.
#### Scenario: single crash auto-restarts to started
- **WHEN** `crash(plugin, new Error("worker died"))` is invoked on a `started` plugin
- **THEN** the FSM transitions `started → crashed → starting → started` AND `plugin.crashed` is emitted.
**Verified by:** `src/lifecycle/lifecycle.test.ts::"crash transitions to crashed then auto-restarts to started"` AND `src/lifecycle/lifecycle.test.ts::"crash budget exhaustion parks the plugin"`
---
### Requirement: `ReloadDriver` implements the §8.1 blue-green primitive
The package SHALL ship `class ReloadDriver` that orchestrates the design §8.1 blue-green hot-reload. Each `reload(plugin, opts)` call SHALL:
1. Refuse a concurrent reload with `RELOAD.IN_PROGRESS`.
2. Snapshot the active activation into the rollback journal.
3. Apply `requiresReinstall(prev, next)` (from `api-lifecycle`).
4. Import the new module under `bustSpecifier(url, N+1)` via the injected `ModuleResolver`.
5. Walk native bindings and refuse the reload with `RELOAD.NATIVE_BINDING_HELD` when any are present.
6. Stage the new handle in the blue-green shadow slot.
7. Drive `lifecycle.startForReload(plugin, probe)` which fires the FSM `start` and the host-level health probe; failure dispatches `rollbackTriggered` so the FSM lands in `rolled_back`.
8. On success, atomically flip the blue-green slot and drain the prior slot.
9. Emit `plugin.reload_total{plugin, trigger, outcome}` + `plugin.reload_duration_ms` histogram.
`blueGreen: false` SHALL opt into the §8.5 503-window mode for environments that cannot host two module instances concurrently.
#### Scenario: happy reload flips blue-green and persists the journal
- **WHEN** the new module imports cleanly AND `start()` AND `probe()` succeed
- **THEN** the blue-green adapter receives `stageShadow → flip → releaseDraining` AND the journal gains an entry AND `lastReloadTrigger` matches the trigger argument.
#### Scenario: concurrent reload is rejected
- **WHEN** a second `reload()` is dispatched while the first is in-flight
- **THEN** the second rejects with `HostError(RELOAD.IN_PROGRESS)`.
#### Scenario: native binding refuses reload
- **WHEN** the resolver's `walkNativeBindings()` returns a non-empty array
- **THEN** the reload throws `HostError(RELOAD.NATIVE_BINDING_HELD)` AND the shadow slot is discarded.
#### Scenario: 503-window opt-in skips blue-green slot manipulation
- **WHEN** the driver is constructed with `blueGreen: false`
- **THEN** the blue-green adapter records no `stage / flip / release` calls AND the reload still completes.
**Verified by:** `src/hot-reload/hot-reload.test.ts::"imports, stages, flips, drains, and persists the journal"` AND `src/hot-reload/hot-reload.test.ts::"rejects a concurrent reload with RELOAD.IN_PROGRESS"` AND `src/hot-reload/hot-reload.test.ts::"throws NATIVE_BINDING_HELD when walk reports bindings"` AND `src/hot-reload/hot-reload.test.ts::"skips blue-green slot manipulation when blueGreen=false"`
---
### Requirement: `RollbackJournal` ring buffer + auto-rollback
The package SHALL maintain a per-plugin `RollbackJournal` (default depth 3, configurable via `journalDepth`) via the in-memory `createRollbackJournal` from `@suaveplan/api-loader`. Each successful activation SHALL push a `RollbackJournalEntry` carrying `specifier`, `manifestHash`, `moduleRef: WeakRef<object>`, `migrationCursor`, `capturedAt`, `version`. The persisted subset (everything except `moduleRef`) SHALL be written to the configured `RollbackPersistence` adapter so host-restart recovery is possible with one additional `import()` call. On a `start` or health-probe failure during reload, the lifecycle FSM SHALL be auto-driven into `rolled_back` via `startForReload`'s `rollbackTriggered` dispatch AND `plugin.rolled_back` SHALL be emitted on the host bus.
#### Scenario: ring buffer truncates to depth on overflow
- **WHEN** five successful activations are recorded against a depth-3 journal
- **THEN** `journal.length === 3` AND only the last three entries remain.
#### Scenario: start failure auto-rolls-back
- **WHEN** the new module's `start` hook throws during reload
- **THEN** the lifecycle FSM lands in `rolled_back` AND `plugin.rolled_back` is emitted AND the persisted journal preserves the prior known-good specifier.
#### Scenario: persisted entries hydrate after restart
- **WHEN** `hydrateFromPersistence(plugin)` is called against a populated `RollbackPersistence`
- **THEN** the journal is re-populated with entries whose `moduleRef` is `null` (re-imported on demand).
#### Scenario: manual rollback flips to the prior journal entry
- **WHEN** `rollback(plugin, 0)` is invoked on a populated journal
- **THEN** the blue-green adapter restages + flips to the prior specifier AND `plugin.rolled_back` is emitted with `cause.code === "ADMIN"`.
**Verified by:** `src/hot-reload/hot-reload.test.ts::"emits plugin.rolled_back and drives lifecycle into rolled_back"` AND `src/hot-reload/hot-reload.test.ts::"rolls back to the prior journal entry"` AND `src/hot-reload/hot-reload.test.ts::"hydrates the journal from the ledger"` AND `src/hot-reload/hot-reload.test.ts::"throws JOURNAL_EMPTY when index exceeds depth"`
---
### Requirement: `FileWatcher` debounces dev-mode reload triggers
The package SHALL ship `class FileWatcher` for the §8.3 dev-mode trigger path. `register(plugin, path, trigger)` SHALL wire a watch via an injected `FsWatchAdapter` and collapse a burst of fs events into a single `trigger(plugin)` invocation per debounce window (default 500ms). The watcher SHALL refuse duplicate registration for the same plugin name. `disposeAll()` SHALL cancel every pending debounce timer + tear down every watch.
#### Scenario: a burst of fs events fires once
- **WHEN** three fs events are emitted in rapid succession
- **THEN** `trigger` is invoked exactly once after the debounce window elapses.
#### Scenario: duplicate registration throws
- **WHEN** `register(plugin, ...)` is called twice with the same `plugin` name
- **THEN** the second call throws synchronously.
#### Scenario: disposer cancels pending fires
- **WHEN** an fs event is debounced AND the disposer is invoked before the timer fires
- **THEN** `trigger` is never invoked AND no timers remain.
**Verified by:** `src/hot-reload/file-watcher.test.ts::"debounces a burst of events into a single trigger"` AND `src/hot-reload/file-watcher.test.ts::"throws on duplicate registration for the same plugin"` AND `src/hot-reload/file-watcher.test.ts::"disposer cancels pending fires + removes the watch"`
---
### Requirement: `PluginLedger` persists the host-owned plugin record
The package SHALL ship `class PluginLedger` backed by a SQLite-shaped `LedgerDb` handle. The ledger SHALL expose `upsertPlugin(record)`, `getPlugin(name)`, `listPlugins()`, `removePlugin(name)`, `pushJournalEntry(plugin, entry)`, `peekJournal(plugin, n)`, `latestJournal(plugin)`, `journalLength(plugin)`. Adapter views `asLifecycleLedger()` and `asRollbackPersistence()` SHALL conform to the lifecycle driver's `LedgerAdapter` and the reload driver's `RollbackPersistence` contracts so production wiring is mechanical. Construction SHALL run host-owned migrations eagerly unless `autoMigrate: false` is passed.
#### Scenario: upsert + read round-trip
- **WHEN** `upsertPlugin({ name: "billing", ... })` is followed by `getPlugin("billing")`
- **THEN** the returned row carries every persisted field (state, version, manifest hash, isolation, counters, capability set).
#### Scenario: malformed `capabilities_json` raises `LEDGER.INVALID_ROW`
- **WHEN** the underlying row's `capabilities_json` is hand-corrupted to a non-JSON string
- **THEN** `getPlugin` throws a `HostError(LEDGER.INVALID_ROW)`.
#### Scenario: `asLifecycleLedger.write` preserves existing counters
- **WHEN** the lifecycle driver writes a partial row against a plugin already carrying `crashCount: 4`
- **THEN** the resulting row carries `crashCount: 4` AND the new `state`, `version`, and `manifestHash`.
**Verified by:** `src/plugin-ledger/plugin-ledger.test.ts::"upserts + reads a plugin row"` AND `src/plugin-ledger/plugin-ledger.test.ts::"raises INVALID_ROW for malformed capabilities JSON"` AND `src/plugin-ledger/plugin-ledger.test.ts::"asLifecycleLedger.write preserves existing counters + isolation"`
---
### Requirement: `PluginLedger` ring-buffers the rollback journal
The journal subset SHALL be stored in `suaveplan_rollback_journal` keyed on `(plugin_name, position)` with `position ∈ [0, journalDepth)` (default depth 3). `pushJournalEntry` SHALL overwrite the oldest slot (by `captured_at` then `position` for ties) when the depth is exceeded. `peekJournal(plugin, n)` SHALL return at most `n` entries ordered newest-first AND validate every row through `rollbackJournalEntryPersistedSchema` from `@suaveplan/api-loader`.
#### Scenario: ring buffer trims oldest on overflow
- **WHEN** four entries are pushed against a depth-3 journal
- **THEN** the journal contains three entries AND the trimmed slot now holds the latest entry.
#### Scenario: malformed entry on push raises a Zod error
- **WHEN** `pushJournalEntry` is called with `manifestHash: "bad"` (not matching the `sha256:` shape)
- **THEN** the call throws synchronously before any row is written.
**Verified by:** `src/plugin-ledger/plugin-ledger.test.ts::"ring-buffer trims oldest on overflow"` AND `src/plugin-ledger/plugin-ledger.test.ts::"rejects malformed entries on push"` AND `src/plugin-ledger/plugin-ledger.test.ts::"ring-buffer breaks ties by lower position"`
---
### Requirement: `HostMigrationRunner` is forward-only and idempotent
The package SHALL ship `class HostMigrationRunner` consuming a `LedgerDb` and an ordered `LedgerMigration[]` (default `HOST_LEDGER_MIGRATIONS`). `migrate()` SHALL apply every declared migration whose `id` has no row in `__host_migrations`, in ascending id order. Re-applying SHALL be a no-op. The runner SHALL refuse to apply when applied ids reference undeclared migrations (`LEDGER.MIGRATION_OUT_OF_ORDER`) and when pending ids are ≤ the maximum applied id. A failed `up` callback SHALL raise `LEDGER.MIGRATION_FAILED` AND NOT record the migration id (so a subsequent retry re-runs it).
#### Scenario: applying twice is a no-op
- **WHEN** `migrate()` is invoked twice against the same DB
- **THEN** the second call returns an empty `readonly number[]`.
#### Scenario: every host-owned table is created
- **WHEN** `migrate()` runs against a fresh DB
- **THEN** `suaveplan_plugin_ledger`, `suaveplan_rollback_journal`, `suaveplan_saga_instances`, `suaveplan_saga_steps`, `suaveplan_2pc_transactions`, `__host_migrations` all exist.
#### Scenario: migration failure parks the runner
- **WHEN** a migration callback throws
- **THEN** the runner raises `LEDGER.MIGRATION_FAILED` AND the migration id is NOT recorded as applied.
**Verified by:** `src/plugin-ledger/plugin-ledger.test.ts::"re-running migrate is a no-op (idempotent)"` AND `src/plugin-ledger/plugin-ledger.test.ts::"creates every host-owned table"` AND `src/plugin-ledger/plugin-ledger.test.ts::"raises MIGRATION_FAILED when an up callback throws"`
---
### Requirement: Admin API mounts every §8.4 endpoint
The package SHALL ship `createAdminApi(options)` returning a `Hono` instance exposing every endpoint listed in design §8.4: `GET /plugins`, `GET /plugins/:name`, `GET /plugins/:name/journal`, `GET /capabilities`, `GET /capabilities/:id`, `GET /health`; `POST /plugins/:name/install`, `POST /plugins/:name/uninstall`, `POST /plugins/:name/start`, `POST /plugins/:name/stop`, `POST /plugins/:name/reload`, `POST /plugins/:name/rollback`, `POST /plugins/:name/restart`. The router SHALL short-circuit the plugin pipeline entirely — every endpoint dispatches against the host-owned `LifecycleDriver`, `ReloadDriver`, `CapabilityRegistry`, and `PluginLedger` references.
#### Scenario: `GET /plugins` returns the ledger snapshot
- **WHEN** a `host:admin` principal hits `GET /plugins`
- **THEN** the response carries `{ plugins: PluginLedgerRow[] }` and 200 status.
#### Scenario: `GET /plugins/:name` returns 404 for unknown
- **WHEN** the requested plugin has no ledger row
- **THEN** the response is `404` with body `{ error: { code: "ADMIN.PLUGIN_NOT_FOUND" } }`.
#### Scenario: `GET /capabilities` enumerates registered ids
- **WHEN** the registry contains at least one capability
- **THEN** the response body's `capabilities` array carries one entry per registered id with `{ id, version, scope }`.
**Verified by:** `src/admin-api/admin-api.test.ts::"returns the ledger snapshot"` AND `src/admin-api/admin-api.test.ts::"returns 404 for unknown plugin"` AND `src/admin-api/admin-api.test.ts::"lists registered capabilities"`
---
### Requirement: Admin API enforces `host:admin` scope + per-principal rate limit
Every admin endpoint SHALL require an authenticated `AdminPrincipal` carrying the configured `requiredScope` (default `host:admin`). The package SHALL ship `AdminRateLimiter` enforcing a per-principal token bucket — `readRatePerMinute` (default 60) for read endpoints and `destructiveRatePerMinute` (default 1) for mutating endpoints. Missing principal or missing scope SHALL raise `ADMIN.RBAC_DENIED` (HTTP 403); bucket exhaustion SHALL raise `ADMIN.RATE_LIMIT_EXCEEDED` (HTTP 429).
#### Scenario: principal without `host:admin` is denied
- **WHEN** the resolver returns a principal whose `scopes` lacks `host:admin`
- **THEN** the response is `403` with `error.code === "ADMIN.RBAC_DENIED"`.
#### Scenario: rate-limit bucket exhaustion rejects with 429
- **WHEN** the configured read RPM is `1` AND the principal makes two `GET /plugins` calls in the same minute
- **THEN** the second response is `429` with `error.code === "ADMIN.RATE_LIMIT_EXCEEDED"`.
#### Scenario: missing principal short-circuits with audit
- **WHEN** the resolver returns `null`
- **THEN** the response is `403` AND the audit sink records a `failure` envelope with `cause.code === "ADMIN.RBAC_DENIED"`.
**Verified by:** `src/admin-api/admin-api.test.ts::"denies principal without scope"` AND `src/admin-api/admin-api.test.ts::"rejects when read bucket is exhausted"` AND `src/admin-api/admin-api.test.ts::"denies missing principal"`
---
### Requirement: Admin API requires `Idempotency-Key` on mutating endpoints
Every mutating endpoint (`install`, `uninstall`, `start`, `stop`, `reload`, `rollback`, `restart`) SHALL require the caller to supply an `Idempotency-Key` header. The header value SHALL be checked against the configured `IdempotencyStore` — a missing header raises `ADMIN.IDEMPOTENCY_KEY_MISSING` (HTTP 400); a replayed key within the TTL window (default 10 minutes) raises `ADMIN.IDEMPOTENCY_KEY_REPLAY` (HTTP 409). The default in-process store SHALL be `InMemoryIdempotencyStore` with lazy TTL pruning.
#### Scenario: install without `Idempotency-Key` is rejected
- **WHEN** a `POST /plugins/:name/install` is dispatched with no `Idempotency-Key` header
- **THEN** the response is `400` with `error.code === "ADMIN.IDEMPOTENCY_KEY_MISSING"`.
#### Scenario: replayed key rejects with 409
- **WHEN** the same `Idempotency-Key` is replayed within the TTL window
- **THEN** the response is `409` with `error.code === "ADMIN.IDEMPOTENCY_KEY_REPLAY"`.
**Verified by:** `src/admin-api/admin-api.test.ts::"install rejects missing Idempotency-Key"` AND `src/admin-api/admin-api.test.ts::"install rejects replayed Idempotency-Key"`
---
### Requirement: Admin API emits audit envelope + OTel instruments
Every admin endpoint outcome SHALL emit a structured `AdminAuditRecord` through the operator-supplied `AdminAuditSink`. The envelope SHALL carry `{ at, principal, action, plugin, params, outcome, cause? }`. Successful + failed outcomes SHALL both produce envelopes so the audit trail captures denied requests. Each call SHALL also increment `host.admin_action_total{action, outcome[, plugin]}` and record the elapsed time on `host.admin_call_duration_ms`. A throwing audit sink SHALL NOT fail the underlying admin operation — the failure SHALL be reported via the metric counter under `action: "host.audit.emit", outcome: "fail"`.
#### Scenario: success envelope is emitted on the happy path
- **WHEN** a `GET /plugins` succeeds
- **THEN** the audit sink receives one record with `outcome: "success"`, `action: "host.plugins.list"`, the resolved principal.
#### Scenario: failure envelope is emitted on RBAC denial
- **WHEN** a principal lacks the required scope
- **THEN** the audit sink receives one record with `outcome: "failure"`, `cause.code: "ADMIN.RBAC_DENIED"`.
#### Scenario: audit sink throw is recorded as a metric, not a 500
- **WHEN** the audit sink throws on `emit`
- **THEN** the admin call still returns 200 AND `host.admin_action_total{action: "host.audit.emit", outcome: "fail"}` increments.
**Verified by:** `src/admin-api/admin-api.test.ts::"records a success envelope on a happy path"` AND `src/admin-api/admin-api.test.ts::"records a failure envelope on rate-limit + idempotency + RBAC denials"` AND `src/admin-api/admin-api.test.ts::"emits audit failure when sink throws"`
---
### Requirement: `LEDGER.*` + new `ADMIN.*` codes are published
The package SHALL extend the `ADMIN_*` namespace with `IDEMPOTENCY_KEY_MISSING` and `IDEMPOTENCY_KEY_REPLAY`. It SHALL publish a new `LEDGER.*` namespace with `MIGRATION_FAILED`, `MIGRATION_OUT_OF_ORDER`, `ROW_NOT_FOUND`, `INVALID_ROW`, `DB_UNAVAILABLE`. Every new code SHALL have a non-zero `HOST_DEFAULT_STATUS_MAP` entry.
#### Scenario: every new code is mapped
- **WHEN** every `LEDGER.*` value is looked up via `statusFor`
- **THEN** the returned status is a 4xx or 5xx integer.
**Verified by:** `src/error-codes/error-codes.test.ts::"LEDGER_CODES carries every documented LEDGER.* reason"` AND `src/error-codes/error-codes.test.ts::"ADMIN_CODES carries every documented ADMIN.* reason"` AND `src/error-codes/error-codes.test.ts::"HOST_DEFAULT_STATUS_MAP maps every published code to a sane HTTP status"`
---
### Requirement: ErrorBoundary projects every captured throw to a stable envelope shape
The package SHALL provide an `ErrorBoundary` consumed by every per-request pipeline step. Each invocation SHALL accept a step name, plugin name (`null` for pre-match steps), per-plugin status-map overlay, optional span, and the work function; it SHALL ALWAYS resolve with a `BoundaryOutcome` (`ok: true | false`), never throw. Projections SHALL follow design doc §12.7:
- `SuaveplanError` / `HostError` → manifest `statusMap[code]` ?? `HOST_DEFAULT_STATUS_MAP[code]` ?? `500` (code preserved);
- `AbortError` (or `code === "ABORT_ERR"`) → `HOST.CLIENT_ABORTED` (499);
- anything else → `HOST.INTERNAL_INVARIANT` (500) — `runHandler` substitutes `HOST.HANDLER_THREW` so handler bugs are slice-able in OTel.
Every failure SHALL increment `api.errors_total{step, code, status[, plugin]}`. When a span is supplied, the boundary SHALL call `recordException` + `setStatus({code: 2, message: <code>})` but SHALL NOT `.end()` the span — the pipeline owns termination. A structured log line SHALL accompany every failure with a cause chain truncated at depth 5.
#### Scenario: manifest statusMap overlays default
- **WHEN** the manifest declares `errors.statusMap["HOST.SESSION_REQUIRED"] = 418`
- **THEN** a `HostError("HOST.SESSION_REQUIRED")` thrown inside the wrapped step produces a failure with `status === 418`.
#### Scenario: unmapped code falls back to 500 with code preserved
- **WHEN** a plugin throws `PLUGIN.X.Y` with no entry in either map
- **THEN** the boundary returns `status: 500` AND `code: "PLUGIN.X.Y"`.
#### Scenario: AbortError → 499
- **WHEN** a step throws `new Error("aborted")` with `name === "AbortError"`
- **THEN** the failure carries `code: "HOST.CLIENT_ABORTED"` and `status: 499`.
#### Scenario: runHandler distinguishes handler throws
- **WHEN** a `runHandler(...)` call captures `throw new Error("plain")`
- **THEN** the failure carries `code: "HOST.HANDLER_THREW"`.
**Verified by:** `src/error-boundary/error-boundary.test.ts::"manifest statusMap overrides default"` AND `src/error-boundary/error-boundary.test.ts::"unmapped code falls back to 500"` AND `src/error-boundary/error-boundary.test.ts::"AbortError → HOST.CLIENT_ABORTED (499)"` AND `src/error-boundary/error-boundary.test.ts::"runHandler substitutes HOST.HANDLER_THREW on unknown throw"`
---
### Requirement: Request pipeline runs §6 steps in order and is the only entry point for plugin traffic
The package SHALL provide a `RequestPipeline.handle(request: Request)` driver that executes design doc §6 steps in the exact documented order:
1. Route match (host) — returns `HOST.ROUTE_NOT_FOUND` (404) when no route matches.
2. Tenant resolve — non-public routes that fail to resolve a tenant return `HOST.TENANT_NOT_RESOLVED` (400). Public (auth-optional) routes that fail to resolve a tenant fall back according to `HostConfig.defaultTenant` (and the equivalent `RequestPipelineOptions.defaultTenant`): when `undefined` or `null` (the default) `ctx.tenant` and `ctx.tenantId` are propagated as `null`; when set to a `TenantContext` value the value is injected verbatim, preserving the pre-foundation-gap-5 `{id: "public", slug: "public"}` semantics for migrators who opt in explicitly.
3. Session resolve via the configured `SessionResolverChain` — resolver throws emit `api.auth.failures_total`.
4. CSRF — required when `route.options.csrf === true`, the request method is not `GET`/`HEAD`, the route is not `streaming`, AND a principal was resolved. Invalid token → `HOST.CSRF_TOKEN_INVALID` (403).
5. RBAC — `null` principal on a non-public route → `HOST.SESSION_REQUIRED` (401); missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total`.
6. Rate limit — keyed by principal id (when present) else tenant id; when neither is bound (auth-optional + `defaultTenant` unset + resolver miss) the limiter keys off the literal `"anon"`. Bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429) + `api.rate_limit.exceeded_total`.
7. Idempotency — `route.options.idempotent === true` + `Idempotency-Key` header present: replay → `HOST.IDEMPOTENCY_REPLAY` (409) + `api.idempotency.replays_total`.
8. Request validation — Zod body / params / query when `route.options.runtimeValidation !== false` and route is not streaming; failures emit `HOST.REQUEST_VALIDATION_FAILED` (400) + `api.validation.failures_total{where=request}`.
9. Plugin handler — invoked through `ErrorBoundary.runHandler` with a `RequestContext` carrying tenant, principal, capabilities, parsed body/params/query.
10. Response validation — `enforce` (production) → `HOST.RESPONSE_VALIDATION_FAILED` (500) on mismatch; `warn` (development) → success + `api.validation.failures_total{where=response.warn}`.
11. Audit envelope (when `auditable: true`) + OTel telemetry close — every request emits `api.requests_total{route, plugin, status, outcome}` and `api.request_duration_ms` histogram.
The pipeline SHALL always set the `X-Request-Id` response header. Streaming routes (`streaming: true`) SHALL bypass CSRF, request validation buffering, and response validation.
#### Scenario: 11 steps execute in order on happy path
- **WHEN** a `POST /billing/invoices` with valid principal + scope is dispatched
- **THEN** the response is 200 AND `X-Request-Id` is set AND an audit envelope is emitted for `auditable: true` routes.
#### Scenario: CSRF gates cookie-bound non-GET requests
- **WHEN** the route is `csrf: true`, the method is `POST`, the principal is non-null, and the CSRF validator returns `false`
- **THEN** the response is 403 with `code: "HOST.CSRF_TOKEN_INVALID"`.
#### Scenario: streaming bypasses CSRF
- **WHEN** the route is `streaming: true, csrf: true` AND CSRF validator would return `false`
- **THEN** the response still succeeds.
#### Scenario: response validation enforce → 500
- **WHEN** the handler returns `{wrong: true}` against a `responses["200"].body = z.object({ok: z.boolean()})`
- **THEN** the response is 500 with `code: "HOST.RESPONSE_VALIDATION_FAILED"`.
#### Scenario: response validation warn → success + counter
- **WHEN** the pipeline is constructed with `responseValidation: "warn"` and the handler's body mismatches the response schema
- **THEN** the response is 200 AND `api.validation.failures_total{where: "response.warn"}` increments.
**Verified by:** `src/request-pipeline/request-pipeline.test.ts::"happy path 200 with X-Request-Id + audit envelope"` AND `src/request-pipeline/request-pipeline.test.ts::"CSRF required on cookie-auth non-GET, invalid → 403"` AND `src/request-pipeline/request-pipeline.test.ts::"streaming routes bypass CSRF"` AND `src/request-pipeline/request-pipeline.test.ts::"response Zod mismatch in enforce mode → 500"` AND `src/request-pipeline/request-pipeline.test.ts::"response Zod mismatch in warn mode → success"`
---
### Requirement: `createHost(config)` composes every internal driver
The package SHALL expose a top-level `createHost(config: HostConfig)` entry point that composes the lifecycle driver, reload driver, plugin ledger, capability registry, request pipeline, and admin API into a `Host` whose `fetch(request)` method drives the full §6 pipeline. Config SHALL be Zod-validated at the entry; failures raise `HOST.CONFIG_INVALID`. `host.start()` SHALL topo-sort `config.plugins` via `@suaveplan/api-manifest`'s `resolveInstallOrder` and install each plugin in dependency order. `host.stop(graceMs)` SHALL stop plugins in reverse-topo order with a `HOST.SHUTDOWN_TIMEOUT` deadline. The host SHALL mount the admin API at `config.adminPrefix` (default `/admin`, `null` to disable). It SHALL expose `/internal/livez` (200 when responsive) and `/internal/readyz` (200 + plugin list). `installPlugin`/`uninstallPlugin` SHALL operate at runtime; pre-start fetches SHALL return a 503 envelope.
`HostAdapters` SHALL distinguish the host-private narrow SQLite ledger handle from the consumer-facing multi-engine database surface:
- `HostAdapters.ledgerDb: LedgerDb` — REQUIRED. Narrow `bun:sqlite` / `better-sqlite3`-style handle (`prepare(sql)` / `run(sql)`) consumed exclusively by the host's internal `PluginLedger`. NOT exposed on `PluginContext` / `RequestContext`.
- `HostAdapters.db?: AdminDatabaseLike` — OPTIONAL. The consumer-facing multi-engine façade produced by `createDbAdapter(...).admin` (`@suaveplan/api-db-adapter`). When set, the host SHALL thread the admin surface onto `PluginContext.db` AND per-request SHALL apply `createTenantScopedWrapper(admin, tenantCtx.id)` and expose the result on `RequestContext.db`. When unset, BOTH `PluginContext.db` AND `RequestContext.db` SHALL be `null`. When the per-request tenant resolver chain returns `null`, `RequestContext.db` SHALL be `null` even if `adapters.db` is configured.
The two slots are type-incompatible by design and SHALL NOT be unified.
#### Scenario: empty host boots; livez/readyz respond
- **WHEN** `createHost({adapters, plugins: []})` + `host.start()` runs
- **THEN** `host.fetch(GET /internal/livez)` returns 200 AND `host.fetch(GET /internal/readyz)` returns 200 with `plugins: []`.
#### Scenario: admin disabled via `adminPrefix: null`
- **WHEN** `adminPrefix: null` is configured
- **THEN** a fetch to `/admin/plugins` is treated as an unknown plugin route and returns 404.
#### Scenario: start twice raises HOST.ALREADY_STARTED
- **WHEN** `host.start()` is called twice
- **THEN** the second call rejects with `code: "HOST.ALREADY_STARTED"`.
#### Scenario: stop before start raises HOST.NOT_STARTED
- **WHEN** `host.stop()` is called before `host.start()`
- **THEN** the call rejects with `code: "HOST.NOT_STARTED"`.
#### Scenario: adapters.db is threaded to ctx.db with tenant-scoped wrapper
- **WHEN** the host is booted with `adapters.db = createDbAdapter(...).admin` AND a tenant resolver that binds `{id: "acme", ...}` AND a route handler reads `ctx.db?.query("SELECT * FROM widgets", [])`
- **THEN** the SQL the admin surface sees is rewritten to `SELECT * FROM widgets WHERE tenant_id = ?` AND the appended bind value is `"acme"` AND `ctx.db.tenantId === "acme"`.
#### Scenario: ctx.db is null when adapters.db is absent
- **WHEN** the host is booted WITHOUT `adapters.db` AND a route handler reads `ctx.db`
- **THEN** the value is `null` AND the handler runs to completion.
#### Scenario: ctx.db is null when no tenant resolved
- **WHEN** the host is booted WITH `adapters.db` AND the tenant resolver chain returns `null` AND `defaultTenant` is unset
- **THEN** `RequestContext.db === null` at the handler boundary.
**Verified by:** `src/host/host.test.ts::"boots with zero plugins + serves /internal/livez 200"` AND `src/host/host.test.ts::"/internal/readyz lists empty plugin set"` AND `src/host/host.test.ts::"adminPrefix: null disables admin surface"` AND `src/host/host.test.ts::"start twice → HOST.ALREADY_STARTED"` AND `src/host/host.test.ts::"stop before start → HOST.NOT_STARTED"` AND `src/host/host.test.ts::"validates config via Zod"` AND `src/host/host.test.ts::"adapters.db threaded into RequestContext via tenant-scoped wrapper"` AND `src/host/host.test.ts::"ctx.db === null when adapters.db is undefined"` AND `src/host/host.test.ts::"ctx.db === null when tenant resolver returns null even if adapters.db is set"`
---
### Requirement: per-request `HOST.*` error codes are published
The package SHALL publish the following `HOST.*` codes for the per-request pipeline: `TENANT_NOT_RESOLVED` (400), `SESSION_REQUIRED` (401), `CSRF_TOKEN_INVALID` (403), `RBAC_DENIED` (403), `RATE_LIMIT_EXCEEDED` (429), `REQUEST_VALIDATION_FAILED` (400), `RESPONSE_VALIDATION_FAILED` (500), `IDEMPOTENCY_REPLAY` (409), `ROUTE_NOT_FOUND` (404), `HANDLER_THREW` (500), `CLIENT_ABORTED` (499). Each new code SHALL have a non-zero `HOST_DEFAULT_STATUS_MAP` entry AND SHALL be thrown by at least one production path covered by tests.
#### Scenario: every new code is mapped
- **WHEN** every Chunk-D `HOST.*` code is looked up via `statusFor`
- **THEN** the returned status is the documented HTTP code above.
**Verified by:** `src/error-codes/error-codes.test.ts::"HOST_CODES carries every documented HOST.* reason"` AND `src/error-codes/error-codes.test.ts::"HOST_DEFAULT_STATUS_MAP maps every published code to a sane HTTP status"` AND `src/request-pipeline/request-pipeline.test.ts` (each code thrown from a real handler path)
---
### Requirement: `HostAdapters.secrets` slot threads through every context
(Added by foundation-fix-gap-3-secrets-slot.)
`HostAdapters` SHALL expose an optional `secrets?: SecretsLike` slot. When the operator omits `secrets`, `createHost` SHALL inject `new EmptySecrets()` into both `RequestPipelineOptions.secrets` and `LifecycleDriverOptions.secrets` so every `RequestContext.secrets` and `PluginContext.secrets` access resolves to a defined `SecretsLike` value. The pipeline driver SHALL set `RequestContext.secrets` to the configured value on every constructed handler context; the lifecycle driver SHALL inject the same value onto every user-hook invocation's `PluginContext`.
#### Scenario: secrets omitted → EmptySecrets default
- **WHEN** `createHost({ adapters: { router, auth, tenant, capabilities, rateLimit, idempotency, csrf, audit, span, db } })` is called without a `secrets` slot
- **THEN** every `RequestContext.secrets.require(<name>)` and `PluginContext.secrets.require(<name>)` throws `SECRETS.SECRET_NOT_FOUND`
#### Scenario: secrets supplied → adapter is threaded
- **WHEN** `createHost({ adapters: { ..., secrets: createEnvSecrets({ allowedKeys: ["FOO"], source: () => ({ FOO: "bar" }) }) } })` is called
- **THEN** a handler's `await ctx.secrets.require("FOO")` resolves to `"bar"`
**Verified by:** `src/request-pipeline/request-pipeline-secrets.test.ts::"handler reads a secret via ctx.secrets.require with an env-style adapter"` AND `src/request-pipeline/request-pipeline-secrets.test.ts::"ctx.secrets is the same reference passed via options"` AND `e2e/foundation-stack/tests/foundation.spec.ts::"secrets adapter — redacted retrieval via env-secrets"`
---
### Requirement: `EmptySecrets` is the host default SecretsLike
(Added by foundation-fix-gap-3-secrets-slot.)
The package SHALL ship `EmptySecrets` implementing `SecretsLike` from `@suaveplan/api-types/secrets`. `get(name)` MUST always resolve to `undefined`. `require(name)` MUST throw `SecretsError` carrying the canonical `SECRETS.SECRET_NOT_FOUND` code (sourced from `@suaveplan/api-secrets`'s `SECRETS_CODES`) and a `context` field of shape `{ name, source: "host.default.empty-secrets" }`. `list()` MUST return a frozen empty `readonly string[]`, stable across calls. The class MUST be reachable via `import { EmptySecrets } from "@suaveplan/api-host/empty-secrets"` AND from the root barrel via `import { EmptySecrets } from "@suaveplan/api-host"`.
#### Scenario: get returns undefined
- **WHEN** `await new EmptySecrets().get("anything")` is called
- **THEN** the resolved value is `undefined`
#### Scenario: require throws SECRETS.SECRET_NOT_FOUND
- **WHEN** `await new EmptySecrets().require("X_API_KEY")` is called
- **THEN** a `SecretsError` is thrown with `code === "SECRETS.SECRET_NOT_FOUND"` AND `context.name === "X_API_KEY"` AND `context.source === "host.default.empty-secrets"`
#### Scenario: list is the frozen empty array
- **WHEN** `new EmptySecrets().list()` is called twice
- **THEN** both calls return the same frozen `readonly string[]` reference whose length is 0
**Verified by:** `src/empty-secrets/empty-secrets.test.ts::"get returns undefined for any key"` AND `src/empty-secrets/empty-secrets.test.ts::"require throws SECRETS.SECRET_NOT_FOUND on every call"` AND `src/empty-secrets/empty-secrets.test.ts::"require attaches the requested key on context"` AND `src/empty-secrets/empty-secrets.test.ts::"list returns an empty readonly array"`
## ADDED Requirements — Reserved-Prefix Contract (2026-05-26)
### Requirement: Publishes the canonical reserved-prefix list
`@suaveplan/api-host` SHALL export `HOST_RESERVED_PREFIXES` as a frozen `ReadonlyArray<string>` of every URL path prefix the host (and any first-party engine that has shipped) reserves. The list MUST be normalised (lowercase, leading-slash, no trailing slash) and deduped. Downstream adapters that mount their own routes MUST consult this list (directly or via `isReservedPrefix`) before mounting and refuse to install on collision.
#### Scenario: list contains every always-reserved host route
- **WHEN** an adapter reads `HOST_RESERVED_PREFIXES`
- **THEN** it contains `/_host` and `/internal` (always-reserved); `/admin`, `/graphql`, `/ws`, `/sse` are included once their respective host modules ship
#### Scenario: list is frozen
- **WHEN** consumer code attempts `HOST_RESERVED_PREFIXES.push("/foo")`
- **THEN** the assignment throws `TypeError`
**Verified by:** `src/reserved-prefix/reserved-prefix.test.ts::"HOST_RESERVED_PREFIXES contains always-reserved prefixes"` AND `src/reserved-prefix/reserved-prefix.test.ts::"HOST_RESERVED_PREFIXES is frozen"`
---
### Requirement: Exposes a predicate + assertion helper
The package SHALL export `isReservedPrefix(prefix: string): boolean` and `assertPrefixAvailable(prefix: string, codeFactory: (collision: string) => Error): void`. The predicate MUST treat input prefixes case-insensitively after normalisation and MUST match both exact prefixes (`/internal` reserves `/internal`) and child paths (`/internal/livez` is also reserved). The assertion MUST throw the error produced by `codeFactory(collision)` when reserved, returning `void` otherwise — letting each adapter surface its own code namespace (`AUTH_BA_*`, `RBAC_*`, …) without coupling.
#### Scenario: predicate matches exact + child paths
- **WHEN** `isReservedPrefix("/internal")` and `isReservedPrefix("/internal/livez")` are called
- **THEN** both return `true`
#### Scenario: predicate normalises case and trailing slash
- **WHEN** `isReservedPrefix("/Internal/")` is called
- **THEN** it returns `true`
#### Scenario: assertion throws the factory-supplied error on collision
- **WHEN** `assertPrefixAvailable("/_host", () => new MyError("X"))` is called
- **THEN** the call throws `MyError("X")`
#### Scenario: assertion is a no-op when free
- **WHEN** `assertPrefixAvailable("/auth", factory)` is called and `/auth` is not in the list
- **THEN** the call returns `void` and `factory` is NOT invoked
**Verified by:** `src/reserved-prefix/reserved-prefix.test.ts::"isReservedPrefix matches exact and child paths"` AND `src/reserved-prefix/reserved-prefix.test.ts::"isReservedPrefix normalises case and trailing slash"` AND `src/reserved-prefix/reserved-prefix.test.ts::"assertPrefixAvailable throws supplied error on collision"` AND `src/reserved-prefix/reserved-prefix.test.ts::"assertPrefixAvailable is no-op when prefix is free"`
---
### Requirement: Published via `./reserved-prefix` subpath
The contract MUST be importable from `@suaveplan/api-host/reserved-prefix` so adapters can pull only the predicate surface without bundling the full host. The subpath MUST forward types and runtime export of the same set documented in `HOST_RESERVED_PREFIXES`, `isReservedPrefix`, `assertPrefixAvailable`, `normalisePrefix`.
#### Scenario: subpath import works in TypeScript and at runtime
- **WHEN** `import { isReservedPrefix } from "@suaveplan/api-host/reserved-prefix"` is used
- **THEN** the symbol resolves AND TypeScript types it as `(prefix: string) => boolean`
**Verified by:** `src/reserved-prefix/reserved-prefix.test.ts::"subpath export resolves and types are forwarded"`

View File

@@ -0,0 +1,146 @@
# Capability: api-jobs-worker-proxy
**Package**: `@suaveplan/api-jobs-worker-proxy`
**Status**: SHIPPED · 2026-05-25
The `@suaveplan/api-jobs-worker-proxy` package provides the worker
↔ main bridge that lets a worker-isolated API plugin participate in
the host's main-process job scheduler. Worker code calls
`registerCron` / `registerQueued` against the local proxy; the proxy
marshals the registration to main via `@suaveplan/api-worker-rpc`,
where it is bound into the host's real `JobRegistrationHandle`. When
the host scheduler ticks, a `runJob` RPC flows back to the worker and
invokes the registered handler closure.
## Requirements
### Requirement: Proxy preserves JobRegistrationHandle surface
The package SHALL expose `createJobsProxyClient(port)` returning an
object implementing `capabilities.JobRegistrationHandle` from
`@suaveplan/api-types`. Method names + parameter shapes MUST match the
direct surface (`registerCron(name, () => Promise<void>): void`,
`registerQueued(name, (payload: unknown) => Promise<void>): void`); the
proxy adds only `close(reason?)` and a `sizes` accessor.
#### Scenario: surface matches JobRegistrationHandle
- **WHEN** a TypeScript consumer assigns the result of
`createJobsProxyClient(port)` to a `capabilities.JobRegistrationHandle`
variable
- **THEN** the assignment type-checks without widening
**Verified by:** `src/client/client.test.ts::"registerCron round-trips, scheduler tick fires handler"` plus typecheck of `src/client/client.ts`.
---
### Requirement: Non-manifested job name is rejected
The package SHALL surface main-side rejection of non-manifested job
names as `JOBS_PROXY.JOB_NOT_DECLARED`. The host enforces — declared in
the plugin's manifest `jobs[]` array, supplied as `declaredJobs` — and
rejects unknown names at the boundary.
#### Scenario: unknown job name rejects
- **WHEN** the worker calls `ctx.jobs.registerCron("unknown-job", fn)`
with an `onHandlerError` observer attached
- **THEN** the observer fires with a `JobsProxyError` whose `code` is
`JOBS_PROXY.JOB_NOT_DECLARED`
**Verified by:** `src/client/client.test.ts::"non-declared job name triggers JOB_NOT_DECLARED via onHandlerError"`
---
### Requirement: Kind mismatch rejects
The package SHALL reject registrations whose kind (`cron` vs `queued`)
does not match the manifest declaration with the same
`JOBS_PROXY.JOB_NOT_DECLARED` code, since the name-to-kind binding is a
manifest invariant.
#### Scenario: cron registered as queued (or vice versa) rejects
- **WHEN** the worker registers a queued-declared name as cron
- **THEN** the rejection observer fires with `JOB_NOT_DECLARED`
**Verified by:** `src/client/client.test.ts::"declared queued name but registered as cron is rejected"`
---
### Requirement: Worker handler exceptions surface back to scheduler
When the main-side scheduler ticks a job and the worker-side handler
throws or rejects, the proxy SHALL propagate the failure to the
scheduler as a `JobsProxyError` of code `JOBS_PROXY.HANDLER_THREW`. The
worker-side observer (if any) also receives the same error.
#### Scenario: handler throws → scheduler tick rejects
- **WHEN** the worker registers a cron handler that throws and the
scheduler ticks that name
- **THEN** the tick promise rejects with a `JobsProxyError` whose
`code` is `JOBS_PROXY.HANDLER_THREW`
**Verified by:** `src/client/client.test.ts::"worker handler that throws surfaces via onHandlerError"`
---
### Requirement: Per-handler timeout is honored
The proxy SHALL apply an optional per-handler timeout
(`handlerTimeoutMs`, default `0` = disabled). When a handler exceeds
the budget the proxy SHALL surface
`JOBS_PROXY.JOB_TIMEOUT` to both the scheduler and the
`onHandlerError` observer.
#### Scenario: long-running handler exceeds budget
- **WHEN** `handlerTimeoutMs` is 5 and the worker handler sleeps 200ms,
then the scheduler ticks
- **THEN** the tick promise rejects and the observer receives
`JOBS_PROXY.JOB_TIMEOUT`
**Verified by:** `src/client/client.test.ts::"handlerTimeoutMs aborts long-running handlers"`
---
### Requirement: Conformance suite passes against this proxy
The package SHALL ship a conformance suite (`JOBS_CONFORMANCE_CASES`
+ `runJobsConformance(factory)`) covering cron round-trip, queued
round-trip with payload, and rejection of non-declared names. The
suite MUST pass against the in-process harness factory.
#### Scenario: runJobsConformance passes
- **WHEN** `runJobsConformance(factory)` runs against the standard
in-process harness
- **THEN** every scenario reports `ok: true`
**Verified by:** `tests/conformance/run-jobs-conformance.test.ts::"every scenario reports ok against the standard harness factory"`
---
## Implementation summary
- Layout: `packages/api-stack/api/api-jobs-worker-proxy/`
- Subpath exports: `./worker-side`, `./main-side`, `./handler-bridge`,
`./error-codes`, `./testing`
- Deps: `@suaveplan/api-types`, `@suaveplan/api-worker-rpc`,
`@suaveplan/error`, `tslib`, `zod`
- Codes (5): `JOB_NOT_DECLARED`, `HANDLER_THREW`, `PROXY_DISPOSED`,
`JOB_TIMEOUT`, `DUPLICATE_REGISTRATION`
- 44 tests / 100/100/100/100 statement/branch/function/line coverage
- Bidirectional over a single `MessagePort`: paired
`createRawWorkerRpcClient` + `serveWorkerRpc` on each side.
## Local substitute for `runJobsConformance`
The proposal referenced
`@suaveplan/job-scheduler/testing::runJobsConformance`. That harness
does not yet exist on genesis. The local
`runJobsConformance` shipped under `./testing` is a forward-compatible
substitute: it takes the same factory shape and returns per-case
pass/fail, so consumers can later swap to the upstream harness with no
public-surface change.

View File

@@ -0,0 +1,165 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-lifecycle (api-lifecycle)
**Package**: `@suaveplan/api-lifecycle`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: PluginState union pinned to design §4
The package SHALL export `PluginState` (re-exported from `@suaveplan/api-types`) with exactly: `"uninstalled" | "installing" | "installed" | "starting" | "started" | "rolled_back" | "crashed" | "stopping" | "stopped" | "uninstalling" | "install_failed" | "start_failed" | "uninstall_failed"`. The package SHALL also export `PLUGIN_STATES` as a runtime tuple matching the union.
#### Scenario: union matches design §4 exactly
- **WHEN** `expectTypeOf<PluginState>()` is checked against the 13 literals
- **THEN** the union is exactly those 13 — no more, no less
#### Scenario: runtime tuple matches the union
- **WHEN** the test asserts `PLUGIN_STATES` covers every union member
- **THEN** every member is present in the tuple
**Verified by:** `src/states/states.spec-conformance.test.ts::"PluginState union matches design §4 exactly"` AND `src/states/states.test.ts::"PLUGIN_STATES tuple covers every union member"`
---
### Requirement: Transitions table is exhaustive
The package SHALL export `PluginTransitions` — a frozen `(from, to, trigger)` table. Every transition documented in design §4's table MUST be present. A transition not in the table MUST throw `LIFECYCLE.ILLEGAL_TRANSITION` when attempted.
#### Scenario: every documented transition is legal
- **WHEN** the test enumerates the §4 table
- **THEN** every documented `(from, to)` pair has an entry in `PluginTransitions`
#### Scenario: undocumented transition rejected
- **WHEN** `transition({ from: "installed", to: "stopped" })` is attempted (illegal: must pass through `starting`/`stopping`)
- **THEN** `LIFECYCLE.ILLEGAL_TRANSITION` is thrown AND `details.from === "installed"` AND `details.to === "stopped"`
**Verified by:** `src/transitions/transitions.test.ts::"every documented transition is legal"` AND `src/transitions/transitions.test.ts::"undocumented transition rejected"`
---
### Requirement: createPluginFsm wires `@suaveplan/fsm`
The package SHALL export `createPluginFsm({ initial, sideEffects, mutex })` returning a `Fsm<PluginState, PluginTrigger>` backed by `@suaveplan/fsm`. Transitions MUST honour the mutex contract (FIFO queue, at most one in-flight per plugin).
#### Scenario: fsm starts in declared initial state
- **WHEN** `createPluginFsm({ initial: "uninstalled", ... })` is created
- **THEN** `fsm.state === "uninstalled"`
#### Scenario: concurrent triggers queue
- **WHEN** two `install` triggers are dispatched concurrently
- **THEN** the second waits until the first completes AND completes against the post-first state (likely rejecting if the FSM left `uninstalled`)
**Verified by:** `src/fsm-factory/fsm-factory.test.ts::"fsm starts in declared initial state"` AND `src/mutex-contract/mutex-contract.test.ts::"concurrent triggers queue FIFO"`
---
### Requirement: Side-effect descriptors per transition
The package SHALL export `SIDE_EFFECTS`: a map from `(from, to)` to the documented side-effect list. `installing → installed` MUST emit `plugin.installed` and complete the OTel span. `started → stopping` MUST pause jobs, unsubscribe events, unmount routes with 503 + Retry-After, call user `stop` hook with `gracefulMs` (default 30000). `stopping → stopped` MUST force-cancel stragglers AND dispose capabilities in reverse-registration order. Every transition documented in §4's table MUST appear here.
#### Scenario: every documented side-effect is in the table
- **WHEN** the test compares `SIDE_EFFECTS` keys against §4's transition table
- **THEN** every documented transition has an entry
#### Scenario: capability disposal order is reverse-registration
- **WHEN** the `stopping → stopped` descriptor is inspected
- **THEN** it declares `disposalOrder: "reverse-registration"`
**Verified by:** `src/side-effects/side-effects.test.ts::"every documented side-effect is in the table"` AND `src/side-effects/side-effects.test.ts::"stopping→stopped disposes in reverse-registration order"`
---
### Requirement: Retry policies
The package SHALL export `RETRY_POLICIES.startFailed` (3 attempts, exponential backoff via `@suaveplan/retry`) and `RETRY_POLICIES.crashed` (default `crashRestartMax: 3`, `crashRestartWindowMs`, `crashRestartCooldownMs`). The `crashed` budget MUST reset after `crashRestartCooldownMs` of healthy operation. Exhausting any budget MUST surface `LIFECYCLE.RETRY_BUDGET_EXHAUSTED`.
#### Scenario: start_failed retried 3 times then parked
- **WHEN** the start hook throws 4 times in a row
- **THEN** the 4th attempt does NOT retry; the plugin is parked AND `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` is the failure code
#### Scenario: crashed budget resets after cooldown
- **WHEN** a plugin crashes twice within the window, then runs healthily for `crashRestartCooldownMs`, then crashes again
- **THEN** the post-cooldown crash is treated as the first crash in a new budget window
**Verified by:** `src/retry-policies/retry-policies.test.ts::"start_failed retried 3 times then parked"` AND `src/retry-policies/retry-policies.test.ts::"crashed budget resets after cooldown"`
---
### Requirement: requiresReinstall criteria
The package SHALL export `requiresReinstall(prev: PluginManifest, next: PluginManifest): boolean` returning `true` IFF any of: `next.database.schemaVersion > prev.database.schemaVersion`; `next.scopes` adds names not in `prev.scopes`; `next.events.publishes` or `next.events.subscribes` add new topics; `next.jobs.cron` or `next.jobs.queued` add new names; `next.secrets` adds a new secret; `next.dependsOn` differs from `prev.dependsOn`. A downgrade (`next.version < prev.version`) MUST throw `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
#### Scenario: identical manifest returns false
- **WHEN** `prev` and `next` are deep-equal
- **THEN** `requiresReinstall(prev, next) === false`
#### Scenario: schemaVersion bump returns true
- **WHEN** `next.database.schemaVersion` is greater than `prev`'s
- **THEN** the result is `true`
#### Scenario: version downgrade rejected
- **WHEN** `semver.lt(next.version, prev.version)` is true
- **THEN** the call throws `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE`
**Verified by:** `src/reinstall-criteria/reinstall-criteria.test.ts::"identical manifest returns false"` AND `src/reinstall-criteria/reinstall-criteria.test.ts::"schemaVersion bump returns true"` AND `src/reinstall-criteria/reinstall-criteria.test.ts::"version downgrade throws"`
---
### Requirement: Mutex contract
The package SHALL export `PluginMutex` interface: `acquire(plugin: string): Promise<Release>`, `tryAcquire(plugin: string): Release | null`. At most one acquired release per plugin. Concurrent acquires queue FIFO. The interface is types-only; concrete impl lives in `api-host`.
#### Scenario: every documented method present
- **WHEN** `expectTypeOf<PluginMutex>()` is checked
- **THEN** `acquire` and `tryAcquire` are declared
#### Scenario: tryAcquire returns null when held
- **WHEN** the conformance harness exercises a stub: acquire then `tryAcquire`
- **THEN** the second call returns `null`
**Verified by:** `src/mutex-contract/mutex-contract.spec-conformance.test.ts::"PluginMutex declares acquire tryAcquire"` AND `src/mutex-contract/mutex-contract.test.ts::"tryAcquire returns null when held"`
---
### Requirement: Error codes registry frozen
The package SHALL export `LIFECYCLE_CODES` containing exactly: `ILLEGAL_TRANSITION`, `MUTEX_HELD`, `RETRY_BUDGET_EXHAUSTED`, `REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `LIFECYCLE_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every LIFECYCLE_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for host driver
The package SHALL export `runLifecycleConformance({ factory, label })`. The Stage-2 `api-host` driver MUST pass it.
#### Scenario: harness covers states, transitions, side-effects, retry budgets, mutex, reinstall criteria
- **WHEN** the harness runs against a stub driver
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers states transitions side-effects retry-budgets mutex reinstall-criteria"`

View File

@@ -0,0 +1,142 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-loader (api-loader)
**Package**: `@suaveplan/api-loader`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line. Types-only files are noted where coverage is N/A.
## ADDED Requirements
### Requirement: ModuleResolver contract
The package SHALL export `ModuleResolver` interface: `resolve({ specifier, version }): Promise<ModuleHandle>` (imports the module under the cache-busted specifier); `dispose(handle): Promise<void>` (releases the host's strong ref so a `WeakRef` may collect); `walkNativeBindings(specifier): Promise<readonly string[]>` (returns paths of `.node` files reachable from the entry). Types-only — coverage N/A for this file. Concrete impl lives in `api-host`.
#### Scenario: every documented method present
- **WHEN** `expectTypeOf<ModuleResolver>()` is checked
- **THEN** `resolve`, `dispose`, `walkNativeBindings` are declared with the documented signatures
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts::"ModuleResolver declares resolve dispose walkNativeBindings"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: bustSpecifier algorithm
The package SHALL export `bustSpecifier(url: string, version: number): string`. The function MUST append `?v=${version}` to a clean URL AND REPLACE an existing `?v=N` suffix when called on an already-busted URL. The result MUST be a valid file URL (host MUST be able to `import()` it).
#### Scenario: clean URL gets ?v=N appended
- **WHEN** `bustSpecifier("file:///abs/x.js", 1)` is called
- **THEN** the result is `"file:///abs/x.js?v=1"`
#### Scenario: re-busting replaces the version
- **WHEN** `bustSpecifier("file:///abs/x.js?v=1", 2)` is called
- **THEN** the result is `"file:///abs/x.js?v=2"` (NOT `"...?v=1?v=2"`)
**Verified by:** `src/cache-bust/cache-bust.test.ts::"clean URL gets ?v appended"` AND `src/cache-bust/cache-bust.test.ts::"re-busting replaces version"`
---
### Requirement: RollbackJournal ring buffer
The package SHALL export `RollbackJournal` interface: `push(entry)`, `peek(n): readonly RollbackJournalEntry[]` (newest first), `latest(): RollbackJournalEntry | null`, `length: number`, `clear(): void`. Default depth `3` (constructor parameter). `push` beyond capacity MUST drop the oldest entry. Types-only — coverage N/A. Concrete impl in `api-host`.
#### Scenario: peek returns newest first
- **WHEN** entries A, B, C are pushed in order
- **THEN** `peek(3)` returns `[C, B, A]`
#### Scenario: push beyond capacity drops oldest
- **WHEN** four entries are pushed to a depth-3 journal
- **THEN** the first entry is dropped AND `length === 3`
#### Scenario: latest returns the most recent or null
- **WHEN** the journal is empty AND `latest()` is called
- **THEN** it returns `null`
**Verified by:** `src/rollback-journal/rollback-journal.test.ts::"peek returns newest first"` AND `src/rollback-journal/rollback-journal.test.ts::"push beyond capacity drops oldest"` AND `src/rollback-journal/rollback-journal.test.ts::"latest returns null on empty"`
---
### Requirement: Persisted-entry Zod schema
The package SHALL export `rollbackJournalEntryPersistedSchema: z.ZodSchema<PersistedRollbackEntry>` covering only the fields that survive host restart: `{ specifier: string, manifestHash: string, migrationCursor: number, capturedAt: string, version: string }`. The in-memory `moduleRef: WeakRef` is type-only and NOT in this schema.
#### Scenario: persisted shape parses
- **WHEN** a valid persisted entry is parsed
- **THEN** parsing succeeds AND `moduleRef` is absent from the result
#### Scenario: missing required field rejected
- **WHEN** an entry missing `manifestHash` is parsed
- **THEN** Zod reports an issue at `manifestHash`
**Verified by:** `src/journal-entry/journal-entry.test.ts::"persisted shape parses without moduleRef"` AND `src/journal-entry/journal-entry.test.ts::"missing required field rejected"`
---
### Requirement: Native-binding detector contract
The package SHALL document that `walkNativeBindings(specifier)` MUST return the paths of every `.node` file reachable from the entry's import subgraph. When the returned array is non-empty, the host's reload path MUST throw `LOADER.NATIVE_BINDING_HELD` and refuse the reload — pure-JS reload is the only mode supported on natives.
#### Scenario: native binding in subgraph triggers refusal
- **WHEN** a stub detector returns `["/x/y.node"]` AND a host reload check runs
- **THEN** `LOADER.NATIVE_BINDING_HELD` is thrown AND `details.bindings === ["/x/y.node"]`
#### Scenario: pure-JS subgraph passes through
- **WHEN** the detector returns `[]`
- **THEN** the reload check returns without throwing
**Verified by:** `src/native-binding-detector/native-binding-detector.test.ts::"native binding in subgraph triggers refusal"` AND `src/native-binding-detector/native-binding-detector.test.ts::"pure-JS subgraph passes through"`
---
### Requirement: Error codes registry frozen
The package SHALL export `LOADER_CODES` containing exactly: `NATIVE_BINDING_HELD`, `MODULE_IMPORT_FAILED`, `JOURNAL_FULL`, `JOURNAL_ENTRY_INVALID`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `LOADER_CODES`
- **THEN** every code has at least one production-path reference
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every LOADER_CODES entry thrown by production path"`
---
### Requirement: Conformance harnesses for loader + journal
The package SHALL export `runLoaderConformance({ factory, label })` and `runRollbackJournalConformance({ factory, label })`. `api-host`'s concrete impls MUST pass both.
#### Scenario: loader harness covers resolve, dispose, native binding refusal
- **WHEN** the harness runs against a stub loader
- **THEN** test categories exist for each documented behavior
#### Scenario: journal harness covers push, peek, capacity, latest, clear
- **WHEN** the harness runs against a stub journal
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/loader-conformance.test.ts::"loader harness covers resolve, dispose, native binding refusal"` AND `src/testing/journal-conformance.test.ts::"journal harness covers push peek capacity latest clear"`

View File

@@ -0,0 +1,70 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-logger-worker-proxy
**Package**: `@suaveplan/api-logger-worker-proxy`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: Proxy preserves LoggerLike surface with plugin pre-tagging
The package SHALL expose `createLoggerProxyClient(port, { plugin: { name, version } })` returning a `WorkerProxyOf<LoggerLike>`. Every emitted record SHALL carry the plugin's `name` and `version` as pre-tagged fields per design doc §5.1. Every log level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) MUST round-trip through the proxy.
#### Scenario: every level round-trips with plugin tags
- **WHEN** the worker calls `log.info({ msg: "x" })`
- **THEN** the main-side stub sink receives a record with `plugin === { name, version }` AND `msg === "x"` AND `level === "info"`
**Verified by:** `src/client/client.test.ts::"every level round-trips with plugin tags"`
---
### Requirement: Batched send flushes on size, idle, or error level
The package SHALL batch outbound log records. A batch SHALL flush when ANY of three conditions hold: (a) `maxBatchSize` records buffered (default 100); (b) `flushIntervalMs` elapsed since the first buffered record (default 50); (c) the record's level is `error` or `fatal` (immediate flush). Buffered records MUST persist across the immediate flush — flushing for an `error` record does NOT discard lower-level records buffered before it.
#### Scenario: batch flushes at max size
- **WHEN** 100 records are buffered
- **THEN** the batch is sent on the next microtask
#### Scenario: error level flushes immediately AND preserves earlier records
- **WHEN** 5 `info` records are buffered AND a `log.error(...)` is called
- **THEN** the batch flushes within 1 microtask AND the main side receives all 6 records in order
**Verified by:** `src/batching/batching.test.ts::"batch flushes at max size"` AND `src/batching/batching.test.ts::"error flushes immediately and preserves earlier records"`
---
### Requirement: Backpressure drops records and emits a counter rather than throwing
The package SHALL enforce a bounded outstanding-batch budget per port. When the budget is exhausted, new records MUST be dropped silently AND a `LOGGER_PROXY_BACKPRESSURE_DROP` counter MUST be incremented. The proxy MUST NOT throw on log calls — logging is best-effort by design, and an application MUST NOT break because the log sink is slow.
#### Scenario: over-budget records drop without throwing
- **WHEN** the stub sink is paused AND 10000 records are emitted
- **THEN** no exception is thrown AND `LOGGER_PROXY_BACKPRESSURE_DROP` counter is incremented above 0
**Verified by:** `src/batching/backpressure.test.ts::"over-budget records drop without throwing"`
---
### Requirement: Control channel can disable and re-enable worker logs
The package SHALL accept `disable` and `enable` messages on the control channel from the main process. After `disable`, every level method on the worker side MUST become a no-op (records discarded without buffer or batch). After `enable`, normal flow resumes. The main host calls `disable` during plugin shutdown so a worker that is being torn down stops producing records after the main-side sink has been disposed (per design doc §5.5 disposal-in-reverse-order).
#### Scenario: disable makes level methods no-op
- **WHEN** the main sends `disable` AND the worker subsequently calls `log.info(...)`
- **THEN** no record reaches the main-side sink AND no batch is queued
#### Scenario: enable restores normal flow
- **WHEN** the main sends `enable` after a prior `disable`
- **THEN** subsequent `log.info(...)` calls reach the sink normally
**Verified by:** `src/control-channel/control-channel.test.ts::"disable makes level methods no-op"` AND `src/control-channel/control-channel.test.ts::"enable restores normal flow"`

View File

@@ -0,0 +1,160 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-manifest (api-manifest)
**Package**: `@suaveplan/api-manifest`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Plugin manifest Zod schema covers every documented field
The package SHALL export `pluginManifestSchema: z.ZodSchema<PluginManifest>` covering every top-level manifest key documented in design §3: identity (`$schema`, `name`, `version`, `displayName`, `description`, `license`), compat (`apiHost`, `requiredCapabilities`, `optionalCapabilities`), `isolation`, `trustLevel`, `permissions`, `dependsOn`, `routes`, `graphql`, `websockets`, `sse`, `sagas`, `twoPhaseCommit`, `database`, `jobs`, `events`, `secrets`, `config`, `scopes`, `telemetry`, `openapi`, `errors`, `uninstall`. The inferred type MUST be structurally assignable to `PluginManifest` from `@suaveplan/api-types`. Unknown top-level keys MUST be rejected (`.strict()`).
#### Scenario: minimal manifest parses
- **WHEN** the smallest legal manifest (`{ name, version, apiHost }`) is parsed
- **THEN** parsing succeeds AND optional fields are absent (NOT defaulted to empty objects)
#### Scenario: maximal manifest parses
- **WHEN** the full §3 example is parsed
- **THEN** every documented field round-trips with the documented type
#### Scenario: unknown top-level key rejected
- **WHEN** `{ name, version, apiHost, badKey: 1 }` is parsed
- **THEN** `parseManifest` throws `MANIFEST.PARSE_FAILED` AND the Zod issue path includes `badKey`
**Verified by:** `src/schema/plugin-manifest.test.ts::"minimal manifest parses"` AND `src/schema/plugin-manifest.test.ts::"maximal manifest round-trips every field"` AND `src/schema/plugin-manifest.spec-conformance.test.ts::"rejects unknown top-level keys"`
---
### Requirement: `import z from "zod"` everywhere
Every `.ts` file using Zod MUST use the default-import form (`import z from "zod"`). The named-import form (`import z from "zod"`) is forbidden — Bun's monorepo resolution treats the named form as unstable.
#### Scenario: no named import of `z`
- **WHEN** the test greps every `src/**/*.ts` for `import z`
- **THEN** zero matches are returned
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: `parseManifest` and `parseManifestSafe`
The package SHALL export `parseManifest(input: unknown): PluginManifest` (throws `SuaveplanError` with code `MANIFEST.PARSE_FAILED` on failure) and `parseManifestSafe(input: unknown): { ok: true; data: PluginManifest } | { ok: false; error: SuaveplanError }`. The thrown / returned error MUST carry the raw Zod issues array under `cause.issues`.
#### Scenario: throw form on invalid input
- **WHEN** `parseManifest({})` is called
- **THEN** it throws `SuaveplanError` with code `MANIFEST.PARSE_FAILED` AND `error.cause.issues` is a non-empty array
#### Scenario: safe form preserves the error envelope
- **WHEN** `parseManifestSafe({})` is called
- **THEN** the result is `{ ok: false, error }` AND the error's code is `MANIFEST.PARSE_FAILED`
**Verified by:** `src/parse/parse.test.ts::"throw form on invalid input"` AND `src/parse/parse.test.ts::"safe form preserves error envelope"`
---
### Requirement: Cross-field validators that Zod alone can't express
The package SHALL export `validateSagaOwnership`, `validatePermissionsWhenSandboxed`, `validateOpenApiRequiresGraphqlOrRoutes`, and `validateErrorStatusMapShape`. Each validator takes a parsed `PluginManifest` and returns `{ ok: true } | { ok: false; error: SuaveplanError }` with a stable code. `validateSagaOwnership` MUST reject sagas whose steps reference a `plugin` name that is not the manifest's own name and not declared in `dependsOn`. `validatePermissionsWhenSandboxed` MUST reject `isolation: "sandboxed"` manifests missing `permissions.budgets`.
#### Scenario: saga step referencing unknown plugin rejected
- **WHEN** a saga lists a step with `plugin: "@acme/unknown"` AND `dependsOn` does not contain `@acme/unknown`
- **THEN** `validateSagaOwnership` returns `{ ok: false, error: { code: "MANIFEST.INVALID_SAGA_OWNERSHIP" } }`
#### Scenario: sandboxed isolation without budgets rejected
- **WHEN** `isolation: "sandboxed"` AND `permissions.budgets` is absent
- **THEN** `validatePermissionsWhenSandboxed` returns `MANIFEST.SANDBOX_REQUIRES_BUDGETS`
**Verified by:** `src/validators/validate-saga-ownership.test.ts::"rejects step referencing undeclared dependency"` AND `src/validators/validate-permissions-when-sandboxed.test.ts::"sandboxed isolation requires permissions.budgets"`
---
### Requirement: `dependsOn` topological resolver
The package SHALL export `resolveInstallOrder(manifests: readonly PluginManifest[]): readonly PluginManifest[]` returning the manifests in dependency order (dependencies before dependents). Cycles MUST surface `MANIFEST.DEPENDENCY_CYCLE` with the cycle path attached. Missing dependencies (`dependsOn` references a name not in the set) MUST surface `MANIFEST.MISSING_DEPENDENCY`. Version-range mismatches (semver) MUST surface `MANIFEST.DEPENDENCY_VERSION_MISMATCH`.
#### Scenario: order is deterministic regardless of input order
- **WHEN** the same set of manifests is shuffled and resolved
- **THEN** the output order is identical across every shuffle
#### Scenario: cycle reported with the cycle path
- **WHEN** A depends on B AND B depends on A
- **THEN** `MANIFEST.DEPENDENCY_CYCLE` is thrown AND the error's `details.path` is `["A", "B", "A"]`
**Verified by:** `src/dep-graph/dep-graph.test.ts::"order is deterministic across shuffles"` AND `src/dep-graph/dep-graph.test.ts::"reports cycle path in details"`
---
### Requirement: Capability resolver
The package SHALL export `resolveCapabilities({ manifest, registered }): CapabilityResolution`. `registered` is a `readonly CapabilityDescriptor[]` (each with `id`, `version`). Missing required capabilities MUST be reported as errors with code `MANIFEST.CAPABILITY_REQUIRED_MISSING` containing the missing id list. Missing optional capabilities MUST be reported as warnings (resolution still succeeds). Version mismatches between manifest's `apiHost` semver and the registered descriptor's version MUST surface `MANIFEST.CAPABILITY_VERSION_MISMATCH`.
#### Scenario: missing required capability blocks resolution
- **WHEN** `manifest.requiredCapabilities` is `["db"]` AND `registered` is `[]`
- **THEN** resolution returns `{ ok: false, error: { code: "MANIFEST.CAPABILITY_REQUIRED_MISSING", details: { missing: ["db"] } } }`
#### Scenario: missing optional capability surfaces as warning
- **WHEN** `manifest.optionalCapabilities` is `["secrets.bun"]` AND `registered` lacks it
- **THEN** resolution returns `{ ok: true, warnings: [{ code: "MANIFEST.OPTIONAL_CAPABILITY_MISSING", details: { id: "secrets.bun" } }] }`
**Verified by:** `src/capability-resolver/capability-resolver.test.ts::"missing required capability blocks resolution"` AND `src/capability-resolver/capability-resolver.test.ts::"missing optional surfaces as warning"`
---
### Requirement: Canonical manifest hash
The package SHALL export `hashManifest(manifest: PluginManifest): string` returning `"sha256:<hex>"`. The hash MUST be computed over a canonical-JSON encoding: keys sorted lexicographically at every depth, no whitespace, no trailing newline. Two manifests differing only in key insertion order MUST hash identically. Two manifests differing in any value (including nested arrays' order if the array is semantically ordered) MUST hash differently.
#### Scenario: key-order independence
- **WHEN** two manifests with identical content but different top-level key order are hashed
- **THEN** the hashes are equal
#### Scenario: KAT vector
- **WHEN** the minimal manifest `{ name: "x", version: "0.1.0", apiHost: "^1" }` is hashed
- **THEN** the output equals a published vector pinned in the test (so this never silently drifts)
**Verified by:** `src/hash/hash.test.ts::"key-order independent"` AND `src/hash/hash.test.ts::"known-answer vector for minimal manifest"`
---
### Requirement: Error codes registry frozen
The package SHALL export `MANIFEST_CODES` via `defineCodes` (from `@suaveplan/error`) containing exactly: `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`. Every code MUST be thrown (or attached to a warning) by at least one production code path.
#### Scenario: every code is reachable
- **WHEN** the test enumerates `MANIFEST_CODES` and greps each into the production source
- **THEN** every code has at least one production-path reference outside `error-codes.ts` itself
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every MANIFEST_CODES entry is thrown by a production path"`
---
### Requirement: Subpath exports per concern
Every category (`schema`, `parse`, `validators`, `dep-graph`, `capability-resolver`, `hash`, `error-codes`, `testing`) SHALL be reachable via its own subpath export. The root barrel MUST use explicit named re-exports (rule 10) — no `export *`.
#### Scenario: every documented subpath resolves
- **WHEN** the test imports each subpath
- **THEN** every import resolves at type-check + runtime
**Verified by:** `tests/subpaths.test.ts::"every documented subpath is importable"` AND `tests/dep-graph.test.ts::"root barrel has no export-star"`

View File

@@ -0,0 +1,194 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-migrations
**Package**: `@suaveplan/api-migrations`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: MigrationAdapter is the per-dialect surface
The package SHALL export `MigrationAdapter` interface with `{ dialect: MigrationDialect; ensureTable(name): Promise<void>; selectApplied(table): Promise<MigrationRecord[]>; insertApplied(table, record): Promise<void>; deleteApplied(table, id): Promise<void>; execute(content, signal?): Promise<void> }`. Each dialect (`surrealql`/`postgres`/`sqlite`) ships its own `ensureTable` DDL — no hard-coded SurrealQL in the runner. `MigrationRunner` consumes the adapter, not a generic `DatabaseLike`.
#### Scenario: surrealql adapter emits SurrealQL DDL on ensureTable
- **WHEN** `createSurrealqlAdapter(client).ensureTable("_migrations")` is called
- **THEN** the adapter executes `DEFINE TABLE IF NOT EXISTS _migrations SCHEMAFULL; DEFINE FIELD ...`
#### Scenario: postgres adapter emits standard SQL DDL on ensureTable
- **WHEN** `createPostgresAdapter(pgClient).ensureTable("_migrations")` is called
- **THEN** the adapter executes `CREATE TABLE IF NOT EXISTS _migrations (id text primary key, name text unique not null, applied_at timestamptz not null default now(), checksum text not null, duration_ms int not null)`
**Verified by:** `src/adapters/surrealql/surrealql.test.ts::"ensureTable emits SurrealQL DDL"` AND `src/adapters/postgres/postgres.test.ts::"ensureTable emits standard SQL DDL"`
---
### Requirement: Runner applies pending migrations in numerical order
`MigrationRunner.scanDirectory(dir)` SHALL return files matching `fileExtension`, parsing leading digits from filenames (`001_initial.sql` → order `1`) and sorting ascending. `run(files)` SHALL filter out already-applied (by name match against `selectApplied`), apply each pending migration via `adapter.execute(content)`, record via `adapter.insertApplied(table, {id, name, appliedAt, checksum, duration})`, and halt on first failure with `MigrationError(MIGRATION_APPLY_FAILED)`.
#### Scenario: pending applied in order
- **WHEN** `_migrations` contains record `001_a` and directory contains `002_b.sql`, `003_c.sql`
- **THEN** `runFromDirectory(dir)` applies `002_b` then `003_c`
#### Scenario: failure halts the apply loop
- **WHEN** `003_c` throws and there are 4+ pending migrations
- **THEN** the runner returns `[result_for_002_b, result_for_003_c_with_success: false]` and does NOT apply `004_d`
**Verified by:** `src/runner/runner.test.ts::"applies pending in numerical order"` AND `src/runner/runner.test.ts::"halts on first failure"`
---
### Requirement: Checksums are actually validated
When `validateChecksums: true` (default), BEFORE applying any pending migration, the runner SHALL re-read every already-applied migration's file content, compute SHA-256, and compare against the stored checksum. Mismatch SHALL raise `MigrationError(MIGRATION_CHECKSUM_DRIFT)` with `context.{name, expected, actual}` — apply halts.
#### Scenario: checksum drift halts apply
- **WHEN** `001_initial.sql` was applied with checksum `abc`, the file is modified post-apply, and `runFromDirectory(dir)` is called
- **THEN** the call rejects with `MigrationError(MIGRATION_CHECKSUM_DRIFT)` BEFORE attempting any pending migration
#### Scenario: validateChecksums: false skips drift check
- **WHEN** the same scenario runs with `validateChecksums: false`
- **THEN** apply proceeds without drift detection
**Verified by:** `src/runner/runner.test.ts::"checksum drift raises MIGRATION_CHECKSUM_DRIFT"` AND `src/runner/runner.test.ts::"validateChecksums false skips drift check"`
---
### Requirement: AbortSignal cancels between migrations
`runFromDirectory(dir, { signal })` SHALL check `signal.aborted` before each pending migration. If aborted, the in-progress migration completes (per the adapter's `execute` semantics) and the runner returns the partial results array. No further migrations are applied. The runner does NOT inject the signal into the adapter — that's per-adapter discretion.
#### Scenario: pre-aborted signal returns empty
- **WHEN** `runFromDirectory(dir, { signal: AbortSignal.abort() })` is awaited
- **THEN** the result array is empty (no migration applied)
#### Scenario: abort mid-loop returns partial result
- **WHEN** abort fires after 002 succeeds but before 003 starts
- **THEN** the result array contains only the 002 entry
**Verified by:** `src/runner/runner.test.ts::"pre-aborted signal applies nothing"` AND `src/runner/runner.test.ts::"abort mid-loop returns partial result"`
---
### Requirement: Dry-run logs but does not apply
`dryRun: true` SHALL cause `runMigration(file)` to log `[DRY RUN] Would apply: ${name}` and return `{name, success: true, duration: <small>}` without invoking `adapter.execute` or `adapter.insertApplied`.
#### Scenario: dry-run does not call adapter
- **WHEN** `runFromDirectory(dir, { dryRun: true })` is invoked and 3 pending migrations exist
- **THEN** `adapter.execute` is not called, `adapter.insertApplied` is not called, and the result array has 3 entries with `success: true`
**Verified by:** `src/runner/runner.test.ts::"dryRun does not call adapter.execute or insertApplied"`
---
### Requirement: OpenTelemetry counter + histogram per apply
The package SHALL register counter `migrations.applied_total` (attributes `dialect`, `outcome`) and histogram `migrations.apply_duration_ms` (attribute `name`). Both SHALL emit per migration regardless of success/failure.
#### Scenario: counter increments on success
- **WHEN** a successful apply runs against a real in-memory `MeterProvider`
- **THEN** `migrations.applied_total{dialect, outcome: "success"}` has 1 observation
#### Scenario: counter increments on failure
- **WHEN** an apply fails
- **THEN** `migrations.applied_total{dialect, outcome: "error"}` has 1 observation
**Verified by:** `src/otel/otel.test.ts::"applied_total emits success"` AND `src/otel/otel.test.ts::"applied_total emits error"` AND `src/otel/otel.test.ts::"apply_duration_ms records per migration"`
---
### Requirement: Rollback removes the last tracking record only
`rollback()` SHALL fetch the most recently applied migration record (sort by `appliedAt` desc, take 1), call `adapter.deleteApplied(table, lastRecord.id)`, and return `{name, success: true, duration: 0}`. It does NOT attempt to revert the schema — that's application policy. If no records exist, returns `null`.
#### Scenario: rollback removes last record
- **WHEN** records `001`, `002`, `003` are applied and `rollback()` is awaited
- **THEN** `adapter.deleteApplied` is called with id of `003` only
#### Scenario: no records → returns null
- **WHEN** the migrations table is empty and `rollback()` is awaited
- **THEN** the result is `null`
**Verified by:** `src/runner/runner.test.ts::"rollback removes last record"` AND `src/runner/runner.test.ts::"rollback empty returns null"`
---
### Requirement: Every MIGRATION_* code is thrown from a production path
`MIGRATION_CODES` SHALL define exactly 6 codes (`MIGRATION_TABLE_BOOTSTRAP_FAILED`, `MIGRATION_APPLY_FAILED`, `MIGRATION_CHECKSUM_DRIFT`, `MIGRATION_DIRECTORY_NOT_FOUND`, `MIGRATION_INVALID_FILE_NAME`, `MIGRATION_ROLLBACK_NOT_SUPPORTED`). Every code SHALL be raised by at least one production call site.
**Verified by:** `src/api-migrations.spec-conformance.test.ts::"every MIGRATION_* code is thrown from a production path"`
---
### Requirement: Server-tier package shape with per-dialect subpaths
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports for the runner (`./runner/runner`), every shipped adapter (`./adapters/surrealql/surrealql`, `./adapters/postgres/postgres`, `./adapters/sqlite/sqlite`), the OTel layer, and the error codes. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage.
#### Scenario: each dialect adapter is independently importable
- **WHEN** a consumer imports `@suaveplan/api-migrations/adapters/postgres/postgres`
- **THEN** the import succeeds without dragging in the SurrealQL adapter
**Verified by:** `tests/dep-graph.test.ts::"each adapter has its own subpath"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature module"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
---
### Requirement: SurrealQL adapter records the ledger row under SurrealDB v3
`createSurrealqlAdapter.insertApplied(table, record)` SHALL produce a record-id reference in the form `<table>:\`<escaped-id>\`` where `<escaped-id>` is `record.id` with every backtick doubled. The adapter SHALL NOT use the `${table}:$name` form — SurrealDB v3 parses `$param` in the record-id position as a literal ident (not a parameter substitution), so the CREATE is acknowledged but silently records no row, leaving the ledger empty and causing the runner to re-execute completed migrations. After a successful `insertApplied` invocation, a subsequent `selectApplied(table)` on the same connection SHALL return a row whose `name`,`checksum`, and`duration` match the inserted record.
`createSurrealqlAdapter.deleteApplied(table, id)` SHALL produce the matching `<table>:\`<escaped-id>\`` reference so the round-trip from `selectApplied` → `deleteApplied` correctly targets the row that `insertApplied` wrote, regardless of whether `id` is a bare SurrealQL ident.
`createSurrealqlAdapter.selectApplied(table)` SHALL normalise the SurrealDB-returned `id` field to the bare migration name — strip the `${table}:` prefix and any wrapping backticks, and for object-form `RecordId { tb, id }` return the inner `id` string when present. This guarantees `rollback()`'s call to `deleteApplied(table, last.id)` produces the same record-ref shape as the original `insertApplied`.
`createSurrealqlAdapter.insertApplied` SHALL bind `record.appliedAt` as the raw `Date` value, NOT as an ISO 8601 string. SurrealDB v3's `TYPE datetime` column refuses to coerce a `string` into `datetime` and rejects the CREATE with `InternalError: Couldn't coerce value for field 'appliedAt'`. The SDK CBOR-encodes the Date binding as a SurrealDB `datetime` value.
#### Scenario: insertApplied uses backticked record-id literal (v3-safe)
- **WHEN** `createSurrealqlAdapter(client).insertApplied("_migrations", { id: "001", name: "001", appliedAt, checksum: "h", duration: 5 })` is called
- **THEN** the issued SurrealQL is `CREATE _migrations:\`001\` SET name = $name, appliedAt = $appliedAt, checksum = $checksum, duration = $duration` (NOT `CREATE _migrations:$name SET ...`)
#### Scenario: backticks in id are escaped
- **WHEN** `record.id` is `weird\`id`
- **THEN** the issued SurrealQL contains `CREATE _migrations:\`weird\`\`id\` SET ...`
#### Scenario: ledger row is queryable after the migration body succeeds (live v3)
- **WHEN** `runFromDirectory(dir)` applies a single migration against a real SurrealDB v3.x testcontainer
- **THEN** `SELECT name, checksum, duration FROM _migrations` on the same connection returns exactly one row with the migration name and a 64-char-hex `checksum`
#### Scenario: second run sees the ledger and skips already-applied migrations (live v3)
- **WHEN** `runFromDirectory(dir)` runs a second time after the first has applied one migration
- **THEN** the result array is empty AND the ledger still contains exactly one row
#### Scenario: rollback removes the ledger row written by insertApplied (live v3)
- **WHEN** `runner.rollback()` is awaited after one successful apply
- **THEN** the rollback result is `{ name, success: true }` AND the ledger table is empty
#### Scenario: selectApplied normalises returned ids to the bare migration name
- **WHEN** SurrealDB returns `id: "_migrations:\`001_initial\`"` for a row
- **THEN** `selectApplied("_migrations")` returns `{ id: "001_initial", ... }`
**Verified by:** `src/adapters/surrealql/surrealql.test.ts::"insertApplied uses backticked record-id literal (v3-safe)"` AND `src/adapters/surrealql/surrealql.test.ts::"insertApplied escapes backticks inside the migration id"` AND `src/adapters/surrealql/surrealql.test.ts::"insertApplied binds appliedAt as a Date (not a string)"` AND `src/adapters/surrealql/surrealql.test.ts::"deleteApplied uses backticked record-id literal (v3-safe)"` AND `src/adapters/surrealql/surrealql.test.ts::"selectApplied strips backtick-quoted id form"` AND `src/adapters/surrealql/surrealql.test.ts::"selectApplied strips table prefix from string ids"` AND `tests/integration/surrealql-ledger.integration.test.ts::"ledger row is queryable after a migration body succeeds (regression: silent CREATE drop)"` AND `tests/integration/surrealql-ledger.integration.test.ts::"second run sees the ledger and skips already-applied migrations"` AND `tests/integration/surrealql-ledger.integration.test.ts::"rollback() removes the ledger row written by insertApplied (round-trip)"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,70 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-openapi-ui-redoc
**Package**: `@suaveplan/api-openapi-ui-redoc`
**Status**: SHIPPED 2026-05-26
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: ReDoc UI handler renders the host's OpenAPI document
The package SHALL expose `createRedocUiHandler({ openapiUrl, title, theme, expandResponses })` returning an HTTP handler responding with the ReDoc HTML shell pointing at `openapiUrl`.
#### Scenario: handler returns HTML referencing openapiUrl
- WHEN the handler is invoked with `openapiUrl = "/openapi.json"`
- THEN response is `text/html` AND the body references `/openapi.json`
#### Scenario: expandResponses configured
- WHEN `expandResponses: "200,201"`
- THEN the embedded ReDoc config expands those response codes by default
**Verified by:** `src/handler/handler.test.ts::"handler returns HTML referencing openapiUrl"` AND `src/handler/handler.test.ts::"expandResponses is embedded in config"`
---
### Requirement: Per-plugin handler points at filtered URL
The package SHALL expose `createPerPluginRedocUiHandler({ openapiUrlForPlugin, plugin })` whose shell references the plugin-filtered URL.
#### Scenario: per-plugin URL embedded
- WHEN the per-plugin handler is invoked
- THEN the response body references the filtered URL
**Verified by:** `src/per-plugin/per-plugin.test.ts::"per-plugin handler embeds filtered URL"`
---
### Requirement: Options validated by Zod
The package SHALL validate `RedocUiOptions` via a Zod schema. Invalid options raise `OPENAPI_UI_REDOC.INVALID_OPTIONS`.
#### Scenario: invalid option rejected
- WHEN `expandResponses` is set to a non-allowed value
- THEN handler construction throws `OPENAPI_UI_REDOC.INVALID_OPTIONS`
**Verified by:** `src/options/options.test.ts::"invalid expandResponses rejected"`
---
### Requirement: Asset strategy supports cdn and bundled
The package SHALL resolve the ReDoc runtime asset URL from `host.openapi.uiAssets`. `"cdn"` returns the upstream CDN URL; `"bundled"` returns the embedded path. A missing bundled asset raises `OPENAPI_UI_REDOC.ASSETS_MISSING`.
#### Scenario: cdn mode
- WHEN `uiAssets = "cdn"`
- THEN the resolved asset URL points at the documented ReDoc CDN
#### Scenario: bundled mode without asset raises
- WHEN `uiAssets = "bundled"` AND the bundled asset is missing
- THEN handler construction throws `OPENAPI_UI_REDOC.ASSETS_MISSING`
**Verified by:** `src/assets/assets.test.ts::"cdn mode returns CDN URL"` AND `src/assets/assets.test.ts::"bundled mode without asset raises ASSETS_MISSING"`

View File

@@ -0,0 +1,65 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-openapi-ui-scalar
**Package**: `@suaveplan/api-openapi-ui-scalar`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Scalar UI handler renders the host's OpenAPI document
The package SHALL expose `createScalarUiHandler({ openapiUrl, theme, title })` returning an HTTP handler that responds with the Scalar HTML shell. The shell MUST reference `openapiUrl` as its document source.
#### Scenario: handler returns HTML pointing at openapiUrl
- WHEN the handler is invoked with `openapiUrl = "/openapi.json"`
- THEN the response Content-Type is `text/html` AND the body references `/openapi.json` exactly once in the Scalar configuration block
**Verified by:** `src/handler/handler.test.ts::"handler returns HTML referencing openapiUrl"`
---
### Requirement: Per-plugin handler points at filtered URL
The package SHALL expose `createPerPluginScalarUiHandler({ openapiUrlForPlugin, plugin })` whose shell references the plugin-filtered URL.
#### Scenario: per-plugin handler uses filtered URL
- WHEN `createPerPluginScalarUiHandler({ openapiUrlForPlugin: "/openapi.json?plugin=billing", plugin: "billing" })` is invoked
- THEN the response body references that filtered URL
**Verified by:** `src/per-plugin/per-plugin.test.ts::"per-plugin handler embeds filtered URL"`
---
### Requirement: Host config options validated by Zod
The package SHALL validate `ScalarUiOptions` via a Zod schema. Invalid configuration raises `OPENAPI_UI_SCALAR_INVALID_OPTIONS` at host boot before the handler is registered.
#### Scenario: invalid options rejected at boot
- WHEN host config sets `theme` to a non-allowed value
- THEN handler construction throws `OPENAPI_UI_SCALAR_INVALID_OPTIONS`
**Verified by:** `src/options/options.test.ts::"invalid theme is rejected by schema"`
---
### Requirement: Asset strategy supports cdn and bundled modes
The package SHALL resolve the Scalar runtime asset URL from `host.openapi.uiAssets`. `"cdn"` returns the upstream CDN URL; `"bundled"` returns the path to the embedded asset under `dist/assets/`. A missing bundled asset raises `OPENAPI_UI_SCALAR_ASSETS_MISSING`.
#### Scenario: cdn mode returns CDN URL
- WHEN `uiAssets = "cdn"`
- THEN the resolved asset URL points at the documented Scalar CDN
#### Scenario: bundled mode requires file present
- WHEN `uiAssets = "bundled"` AND the bundled asset is missing from `dist/assets/`
- THEN handler construction throws `OPENAPI_UI_SCALAR_ASSETS_MISSING`
**Verified by:** `src/assets/assets.test.ts::"cdn mode returns documented CDN URL"` AND `src/assets/assets.test.ts::"bundled mode without asset raises ASSETS_MISSING"`

View File

@@ -0,0 +1,70 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-openapi-ui-swagger (api-openapi-ui-swagger)
**Package**: `@suaveplan/api-openapi-ui-swagger`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Swagger UI handler renders the host's OpenAPI document
The package SHALL expose `createSwaggerUiHandler({ openapiUrl, title, tryItOutEnabled })` returning an HTTP handler responding with the Swagger UI HTML shell pointing at `openapiUrl`.
#### Scenario: handler returns HTML referencing openapiUrl
- WHEN the handler is invoked with `openapiUrl = "/openapi.json"`
- THEN the response is `text/html` AND the body references `/openapi.json`
#### Scenario: tryItOutEnabled toggles Swagger config
- WHEN `tryItOutEnabled: false`
- THEN the embedded Swagger config disables try-it-out
**Verified by:** `src/handler/handler.test.ts::"handler returns HTML referencing openapiUrl"` AND `src/handler/handler.test.ts::"tryItOutEnabled toggles Swagger config"`
---
### Requirement: Per-plugin handler points at filtered URL
The package SHALL expose `createPerPluginSwaggerUiHandler({ openapiUrlForPlugin, plugin })` whose shell references the plugin-filtered URL.
#### Scenario: per-plugin URL embedded
- WHEN the per-plugin handler is invoked with a filtered URL
- THEN the response body references that filtered URL
**Verified by:** `src/per-plugin/per-plugin.test.ts::"per-plugin handler embeds filtered URL"`
---
### Requirement: Options validated by Zod
The package SHALL validate `SwaggerUiOptions` via a Zod schema. Invalid options raise `OPENAPI_UI_SWAGGER_INVALID_OPTIONS` at boot.
#### Scenario: invalid option rejected at boot
- WHEN `theme` is set to a non-allowed value
- THEN handler construction throws `OPENAPI_UI_SWAGGER_INVALID_OPTIONS`
**Verified by:** `src/options/options.test.ts::"invalid theme rejected by schema"`
---
### Requirement: Asset strategy supports cdn and bundled
The package SHALL resolve the Swagger UI runtime asset from `host.openapi.uiAssets`. `"cdn"` returns the upstream CDN URL; `"bundled"` returns the path to the embedded asset. A missing bundled asset raises `OPENAPI_UI_SWAGGER_ASSETS_MISSING`.
#### Scenario: cdn mode returns CDN URL
- WHEN `uiAssets = "cdn"`
- THEN the resolved asset URL points at the documented Swagger CDN
#### Scenario: bundled mode without asset raises
- WHEN `uiAssets = "bundled"` AND the bundled asset is missing
- THEN handler construction throws `OPENAPI_UI_SWAGGER_ASSETS_MISSING`
**Verified by:** `src/assets/assets.test.ts::"cdn mode returns CDN URL"` AND `src/assets/assets.test.ts::"bundled mode without asset raises ASSETS_MISSING"`

View File

@@ -0,0 +1,228 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-openapi
**Package**: `@suaveplan/api-openapi`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: OpenAPIRegistry builds the full OpenAPI 3.1 document
`OpenAPIRegistry.buildDocument()` SHALL emit a complete `OpenAPIDocument` containing `openapi: "3.1.0"`, `info`, `servers`, `paths` (derived from `registeredRoutes`), `components.{schemas, securitySchemes, parameters, responses}`, `tags`, and `security`. Calling `applyTo(app: OpenAPIHono)` SHALL wire `app.doc(specPath, ...)` so the served doc equals `buildDocument()` output.
#### Scenario: applyTo connects registry to Hono
- **WHEN** routes are registered via `registry.registerRoute(...)` and `registry.applyTo(app)` is called, then a GET on the spec path is made
- **THEN** the response body equals `registry.buildDocument()`
**Verified by:** `src/registry/registry.test.ts::"applyTo serves buildDocument output"` AND `src/registry/registry.test.ts::"registered routes appear in paths"`
---
### Requirement: RouteBuilder preserves Zod types through the chain
`RouteBuilder` SHALL preserve `TParams`, `TQuery`, `TBody`, `TResponse` generics through `.params/.query/.body/.response/.build`. The output `TypedRouteConfig` SHALL be assignable to `@hono/zod-openapi`'s `RouteConfig` so `app.openapi(route, handler)` infers `c.req.valid()` types automatically.
#### Scenario: RouteBuilder produces typed inference
- **WHEN** `route("post", "/users").body(UserCreateSchema).response(UserSchema).build()` is fed into `app.openapi(route, handler)` and the handler calls `c.req.valid("json")`
- **THEN** the type of the result equals `z.infer<typeof UserCreateSchema>` (no `unknown` widening)
**Verified by:** `src/route-factory/route-factory.spec-conformance.test.ts::"RouteBuilder threads types end-to-end"` AND `src/route-factory/route-factory.test.ts::"build produces TypedRouteConfig assignable to RouteConfig"`
---
### Requirement: StandardResponses cover the full SuaveplanError statusCode range
`StandardResponses` SHALL include entries for HTTP `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`, `503`, `504` — each pointing at `ErrorResponseSchema`. Upstream's narrower 400/401/403/404/500-only enum is replaced.
#### Scenario: 422 + 503 + 504 are present
- **WHEN** `StandardResponses` is inspected
- **THEN** keys `422`, `503`, `504` exist and reference `ErrorResponseSchema`
**Verified by:** `src/route-factory/standard-responses.test.ts::"covers 422/429/503/504"` AND `src/route-factory/standard-responses.test.ts::"every entry references ErrorResponseSchema"`
---
### Requirement: ErrorResponseSchema matches @suaveplan/error wire shape
`ErrorResponseSchema` SHALL be `z.object({ success: z.literal(false), error: z.object({ code: z.string(), message: z.string(), details: z.record(z.string(), z.unknown()).optional(), requestId: z.string().optional(), timestamp: z.string(), cause: z.unknown().optional() }) })` — a 1:1 match for `SuaveplanError.toJSON()`. The cause field is added to support genesis's cause-chain feature.
#### Scenario: ErrorResponseSchema accepts a SuaveplanError JSON
- **WHEN** a `SuaveplanError`'s `toJSON()` output is passed to `ErrorResponseSchema.safeParse`
- **THEN** the parse succeeds
**Verified by:** `src/route-factory/error-response-schema.test.ts::"accepts SuaveplanError.toJSON() output"` AND `src/route-factory/error-response-schema.test.ts::"rejects success:true payload"`
---
### Requirement: SchemaRegistry has no global singleton
The package SHALL export `SchemaRegistry` class and `createSchemaRegistry()` factory only — no `getSchemaRegistry()` global accessor. `defineSchema(schema, meta, registry)` SHALL require an explicit `registry: SchemaRegistry` argument. This is a deliberate behaviour change from upstream (which has a global `globalSchemaRegistry` singleton) to eliminate parallel-test conflicts.
#### Scenario: no global accessor exported
- **WHEN** the package barrel is inspected for `getSchemaRegistry`
- **THEN** the symbol is not exported
#### Scenario: defineSchema requires explicit registry
- **WHEN** `defineSchema(MySchema, {name: "X"})` is called with no registry argument
- **THEN** the compile fails
**Verified by:** `src/schema-registry/schema-registry.spec-conformance.test.ts::"no global singleton exported"` AND `src/schema-registry/schema-registry.spec-conformance.test.ts::"defineSchema requires explicit registry argument"`
---
### Requirement: SchemaRegistry derivation helpers preserve ZodObject typing
`SchemaRegistry.extend/partial/pick/omit` SHALL operate only on `ZodObject` base schemas. Calling them with a non-object base SHALL raise `OpenAPIError(OPENAPI_INVALID_BASE_SCHEMA)`. Successful derivations SHALL be registered under the new name and returned.
#### Scenario: extend non-object raises typed error
- **WHEN** `registry.register("primitives.uuid", z.string().uuid())` then `registry.extend("primitives.uuid", "X", { extra: z.string() })` is called
- **THEN** the call raises `OpenAPIError(OPENAPI_INVALID_BASE_SCHEMA)`
#### Scenario: pick + omit derive from registered base
- **WHEN** `User` is registered with fields `{id, email, password}` and `pick("User", "UserPublic", ["id", "email"])` is called
- **THEN** `registry.get("UserPublic")` returns a ZodObject with only `id` and `email`
**Verified by:** `src/schema-registry/schema-registry.test.ts::"extend non-object raises OPENAPI_INVALID_BASE_SCHEMA"` AND `src/schema-registry/schema-registry.test.ts::"pick derives + registers"` AND `src/schema-registry/schema-registry.test.ts::"omit derives + registers"`
---
### Requirement: Doc merger preserves base paths and dedups tags
`mergeOpenAPIDocuments(base, ...docs)` SHALL merge `paths` per-path-item (collision = method union), spread-merge `components.{schemas, securitySchemes, parameters, responses}`, and deduplicate `tags` by name. `prefixPaths(doc, prefix)` SHALL prepend `prefix` to every path (collapsing `//`). `filterPathsByTag(doc, tags)` SHALL keep only operations whose `tags` intersect. `removeInternalRoutes(doc)` SHALL drop operations marked `x-internal: true`.
#### Scenario: merge composes two docs
- **WHEN** `mergeOpenAPIDocuments(base, addonDoc)` is invoked where `base.paths["/a"]` exists and `addonDoc.paths["/b"]` exists
- **THEN** the merged doc contains both paths
#### Scenario: prefix collapses double slashes
- **WHEN** `prefixPaths(doc, "/api/")` is called and `doc.paths["/users"]` exists
- **THEN** the result has `/api/users` (single slash, not `//`)
**Verified by:** `src/merger/merger.test.ts::"merge composes paths"` AND `src/merger/merger.test.ts::"prefix collapses double slashes"` AND `src/merger/merger.test.ts::"filterPathsByTag keeps tag-intersecting ops"` AND `src/merger/merger.test.ts::"removeInternalRoutes drops x-internal: true"`
---
### Requirement: definePluginRoutes registers in both Hono and registry atomically
The package SHALL export `definePluginRoutes(routes, { app, registry, pluginName })` helper. For each route, it SHALL call `app.openapi(route, handler)` AND `registry.registerRoute({ method, path, tags, operationId, pluginName, summary })`. The two calls are paired so the registry and Hono cannot drift.
#### Scenario: registers in both surfaces
- **WHEN** `definePluginRoutes([myRoute], { app, registry, pluginName: "users" })` is called
- **THEN** `app.routes` contains the route AND `registry.getRoutesByPlugin("users")` returns it
**Verified by:** `src/plugin-integration/plugin-integration.test.ts::"registers in Hono and registry together"`
---
### Requirement: Every OPENAPI_* code is thrown from a production path
`OPENAPI_CODES` SHALL define exactly 7 codes (`OPENAPI_INVALID_INPUT`, `OPENAPI_SCHEMA_NOT_FOUND`, `OPENAPI_INVALID_BASE_SCHEMA`, `OPENAPI_MISSING_SCHEMA`, `OPENAPI_SCHEMA_CONVERSION_FAILED`, `OPENAPI_OPERATION_ID_COLLISION`, `OPENAPI_UNKNOWN_PLUGIN_IN_FILTER`). Every code SHALL be raised by at least one production call site.
**Verified by:** `src/api-openapi.spec-conformance.test.ts::"every OPENAPI_* code is thrown from a production path"` AND `src/error-codes/error-codes.test.ts::"defines exactly 7 codes"`
---
### Requirement: OpenAPI 3.1 document emitted from Zod schemas
`createOpenapiEmitter({ manifests, routes, authAdapter, hostConfig })` SHALL ingest every plugin manifest plus every registered route's request/response schemas and produce a single OpenAPI 3.1 document. The emitter memoises the document — repeated `getDocument()` returns the same reference.
#### Scenario: full document covers every plugin
- WHEN two plugins are registered each with two routes
- THEN `emitter.getDocument()` returns an OpenAPI 3.1 document with paths for all four routes
**Verified by:** `src/emitter/emitter.test.ts::"emits a 3.1 document covering every registered route"`
---
### Requirement: Namespacing rules are deterministic
The package SHALL set tags to each plugin's `displayName` (falling back to `name`), prefix component schemas with `<pluginName>.<TypeName>`, and set `operationId` to `<pluginName>.<method>.<oasPath>`. operationIds MUST be unique across the merged document; collisions raise `OPENAPI_OPERATION_ID_COLLISION`.
#### Scenario: operationId is deterministic
- WHEN a billing plugin declares `GET /invoices/:id`
- THEN the emitted operationId is `billing.get./invoices/{id}`
#### Scenario: operationId collision raises error
- WHEN two plugins register routes that produce identical operationIds
- THEN emission raises `OPENAPI_OPERATION_ID_COLLISION`
**Verified by:** `src/namespacing/namespacing.test.ts::"operationId is deterministic"` AND `src/namespacing/namespacing.test.ts::"collision raises OPENAPI_OPERATION_ID_COLLISION"`
---
### Requirement: Security schemes inherit from the active auth adapter
The emitter SHALL read the active auth adapter's `securitySchemes` contribution and attach the default scheme to every route except those listed in any plugin's `publicRoutes`, which receive `security: []`.
#### Scenario: public route has no security
- WHEN a route is listed in `publicRoutes`
- THEN the emitted operation has `security: []`
#### Scenario: non-public route inherits default scheme
- WHEN a route is not on `publicRoutes`
- THEN the emitted operation lists the auth adapter's default scheme
**Verified by:** `src/emitter/emitter.test.ts::"public route emits empty security"` AND `src/emitter/emitter.test.ts::"non-public route inherits adapter scheme"`
---
### Requirement: Endpoints serve json, yaml, and per-plugin filtered views
The package SHALL expose `GET /openapi.json`, `GET /openapi.yaml`, `GET /docs`, and `GET /docs/{plugin}` handlers via `createOpenapiRouter` / `mountOpenapiEndpoints`. The filtered view MUST return only operations tagged with the named plugin; an unknown plugin name returns HTTP 404 with a body carrying `OPENAPI.UNKNOWN_PLUGIN_IN_FILTER`.
#### Scenario: yaml endpoint returns yaml content-type
- WHEN a client requests `GET /openapi.yaml`
- THEN response Content-Type is `application/yaml` AND the body parses as YAML
#### Scenario: filtered view excludes other plugins
- WHEN `GET /docs/billing` is requested in a host with billing + users plugins
- THEN the response references billing only
**Verified by:** `src/endpoints/endpoints.test.ts::"yaml endpoint returns application/yaml"` AND `src/endpoints/endpoints.test.ts::"filtered view excludes other plugins"` AND `src/endpoints/endpoints.test.ts::"filtered view returns 404 for unknown plugin"`
---
### Requirement: Missing schema is handled per strictness setting
The emitter SHALL read `host.openapi.strictness` (or default from `host.environment` — `production` → `"refuse-install"`, otherwise `"warn"`). When `"warn"`, missing request/response schemas log a warning, increment the `api.openapi.missing_schema_total` OTel counter, AND emit with `description: "schema-missing"`. When `"refuse-install"`, missing schemas raise `OPENAPI_MISSING_SCHEMA` BEFORE the plugin reaches `installed`.
#### Scenario: warn mode emits placeholder and increments warning counter
- WHEN a route has no request or response schema AND strictness is `"warn"`
- THEN emission succeeds AND the warning is logged + the OTel counter increments
#### Scenario: refuse-install raises OPENAPI_MISSING_SCHEMA
- WHEN strictness is `"refuse-install"`
- THEN emission raises `OPENAPI_MISSING_SCHEMA`
**Verified by:** `src/emitter/emitter.spec-conformance.test.ts::"warn mode emits placeholder and increments warning counter"` AND `src/emitter/emitter.spec-conformance.test.ts::"refuse-install raises OPENAPI_MISSING_SCHEMA"` AND `src/strictness/strictness.test.ts::"refuse-install raises OPENAPI_MISSING_SCHEMA"`
---
### Requirement: Server-tier package shape
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage.
**Verified by:** `tests/dep-graph.test.ts::"dependencies match the allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,179 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-audit
**Package**: `@suaveplan/api-plugin-audit`
**Status**: SHIPPED
**Source change**: `openspec/archive/2026-05-18-add-api-plugin-audit/`
> Every requirement carries a `**Verified by:**` line pointing at the concrete test that pins it.
## Requirements
### Requirement: createAuditPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createAuditPlugin(options): PluginDescriptor<AuditPluginConfig>` that validates `options` against `AuditPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `AuditPluginError(AUDIT_PLUGIN_INVALID_INPUT)` carrying the `ZodError` as `cause`. The descriptor SHALL declare `name: "audit"`, `version: "0.1.0"`, resolved `defaults`, and at minimum `onInit` and `onClose` lifecycle hooks.
#### Scenario: factory produces a valid descriptor
- **WHEN** `createAuditPlugin({ secretKey: "x".repeat(32) })` is called with defaults
- **THEN** the returned descriptor has `name === "audit"`, `onInit` and `onClose` are functions
#### Scenario: invalid options raise AUDIT_PLUGIN_INVALID_INPUT
- **WHEN** `createAuditPlugin({ samplingRate: 2 } as never)` is called (samplingRate out of `[0,1]`)
- **THEN** `AuditPluginError(AUDIT_PLUGIN_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createAuditPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise AUDIT_PLUGIN_INVALID_INPUT"`
---
### Requirement: onInit wires the sink, registers auditService, installs middleware
`onInit(ctx)` SHALL resolve the host `app` (OpenAPIHono) from `ServiceRegistry`. When `app` is absent, it SHALL throw `AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)` listing `"app"` in `context.missing`. When `app` is present, it SHALL: (a) construct an `AuditLogger` using the supplied (or default in-memory) `sink`, secretKey, and chain-integrity flag; failures wrap the cause as `AuditPluginError(AUDIT_PLUGIN_SINK_INIT_FAILED)`; (b) register `auditService` in `ServiceRegistry`; (c) install a single `app.use("*", auditMiddleware)` that records one audit event per non-excluded request.
#### Scenario: missing app service raises AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE
- **WHEN** `onInit` runs against a context whose ServiceRegistry has no `app`
- **THEN** `AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)` is thrown with `context.missing` containing `"app"`
#### Scenario: onInit registers auditService and installs middleware
- **WHEN** `onInit` runs with `app` present and a `MemoryAuditAdapter` sink
- **THEN** `ctx.getService("auditService")` returns a non-undefined service AND a subsequent request through `app.fetch(new Request(...))` results in exactly one audit event saved to the sink
**Verified by:** `src/plugin/on-init.test.ts::"raises AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE when app missing"` AND `src/plugin/on-init.test.ts::"registers auditService and installs middleware"` AND `src/plugin/on-init.test.ts::"sink construction failure raises AUDIT_PLUGIN_SINK_INIT_FAILED"`
---
### Requirement: Global middleware records request audit per non-excluded request
The package SHALL export `createAuditMiddleware(deps)` returning a Hono middleware that, for every request whose path is NOT in `excludePaths` AND that passes the `samplingRate` head-end gate: captures `method`, `path`, `request_id` (from `c.req.header("x-request-id")` or `c.get("requestId")` or a generated ULID-like id), redacted `query`, redacted `headers`, optionally redacted body when `recordRequestBody === true`; awaits `next()`; records `status` (from `c.res.status`), `duration_ms`, `response_size` (`Content-Length` header when present and `recordResponseSize === true`), `principal_id` (`c.get("user")?.id`), `tenant_id` (`c.get("tenant")?.id`), and a severity derived from the status (`< 400 → info`, `< 500 → warning`, otherwise `error`). The middleware SHALL never throw into the request path — sink write failures SHALL log at warn AND increment `audit.sink_failures_total` without disturbing the response.
#### Scenario: records a single event per request with principal + tenant
- **WHEN** a request with `c.set("user", { id: "u1" })` and `c.set("tenant", { id: "t1" })` reaches a handler that returns 200
- **THEN** exactly one audit event is saved with `actor.userId === "u1"`, `tenantId === "t1"`, `details.method`, `details.path`, `details.status === 200`, `details.duration_ms` is a number, `severity === info`
#### Scenario: redacts sensitive query, headers, and (when enabled) body
- **WHEN** a request carries `Authorization: Bearer secret`, query `?token=abc&safe=v`, and `recordRequestBody: true` with body `{ password: "x", email: "a@b" }`
- **THEN** the saved event's `details.headers.authorization === "[REDACTED]"`, `details.query.token === "[REDACTED]"`, `details.query.safe === "v"`, `details.body.password === "[REDACTED]"`, `details.body.email === "a@b"`
#### Scenario: excludePaths skip recording
- **WHEN** a request to `/health` arrives with default `excludePaths`
- **THEN** no event is saved AND `audit.requests_excluded_total` increments by 1
#### Scenario: never throws when sink fails
- **WHEN** the configured sink rejects in `.save(...)`
- **THEN** the request still completes with the handler's response AND `audit.sink_failures_total` increments AND the logger receives a `warn` message
#### Scenario: severity classification by status
- **WHEN** the handler returns 404
- **THEN** the saved event's `severity === warning`
- **WHEN** the handler returns 500
- **THEN** the saved event's `severity === error`
**Verified by:** `src/middleware/audit-middleware.test.ts::"records a single event per request with principal and tenant"` AND `src/middleware/audit-middleware.test.ts::"redacts sensitive query headers and body"` AND `src/middleware/audit-middleware.test.ts::"excludePaths skip recording"` AND `src/middleware/audit-middleware.test.ts::"never throws when sink fails"` AND `src/middleware/audit-middleware.test.ts::"severity classification by status"`
---
### Requirement: Sampling rate gates recording at the head end
When `samplingRate < 1`, the middleware SHALL evaluate `rng() < samplingRate` per request; on miss, no event is saved and the request still completes normally. With `samplingRate === 0.5` over 1000 requests using a deterministic RNG, the saved-event count SHALL fall in `[400, 600]`.
#### Scenario: 50% sampling band
- **WHEN** 1000 requests flow through a plugin configured with `samplingRate: 0.5`
- **THEN** the sink contains between 400 and 600 events inclusive
**Verified by:** `tests/integration/sampling.test.ts::"50% sample rate over 1000 requests sits in [400, 600] band"`
---
### Requirement: auditService registered in ServiceRegistry with typed slot
`onInit` SHALL call `ctx.registerService("auditService", auditService)`. The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with `auditService: AuditService`. The service surface SHALL expose `getLogger()`, `log(event)`, `query(opts)`, `verifyChainIntegrity(events)`, `exportGDPRData(userId, signal?)`, `applyRetentionPolicy(signal?)`.
#### Scenario: downstream plugin reads auditService with type safety
- **WHEN** another plugin's hook calls `ctx.getService("auditService")`
- **THEN** the return type is `AuditService | undefined` (no cast) AND the runtime value exposes the 6 declared methods
**Verified by:** `src/services/audit-service.spec-conformance.test.ts::"ServiceRegistry slot typed as AuditService"` AND `src/services/audit-service.test.ts::"log proxies to underlying AuditLogger"` AND `src/services/audit-service.test.ts::"query proxies to underlying AuditLogger"`
---
### Requirement: Handler-level c.get("audit") exposes log + getLogger
The package SHALL augment Hono's `ContextVariableMap` with `audit: AuditContext`. The middleware SHALL set `c.set("audit", context)` BEFORE calling `next()` so handlers can call `c.get("audit").log({ type, severity, target, details })` to record explicit, semantic events alongside the implicit request audit. Handler-level events SHALL be saved via the same sink and counted by the same OTel counter.
#### Scenario: handler logs an explicit event
- **WHEN** a handler calls `await c.get("audit").log({ type: "user.deleted", severity: AuditSeverity.WARNING, actor: { userId: "u1" }, target: { type: "user", id: "u2" } })`
- **THEN** that event appears in the sink in addition to the per-request event
**Verified by:** `src/context/audit-context.test.ts::"log writes through to the underlying AuditLogger"` AND `src/context/audit-context.test.ts::"getLogger returns the underlying AuditLogger instance"` AND `src/middleware/audit-middleware.test.ts::"installs c.get(audit) before next()"`
---
### Requirement: Redaction helper handles query, headers (case-insensitive), and body
The package SHALL export `redactPayload(payload, rules)` returning a shallow-redacted clone where header-name matches are case-insensitive, query and body field-name matches are case-sensitive, and matched values are replaced with the literal string `"[REDACTED]"`. Defaults SHALL cover `authorization`, `cookie`, `set-cookie`, `x-api-key` (headers); `token`, `code`, `state` (query); `password`, `secret`, `token` (body).
#### Scenario: case-insensitive header redaction
- **WHEN** `redactPayload({ headers: { Authorization: "Bearer x", "X-Api-Key": "k" } }, defaultRules)` is called
- **THEN** both values are `"[REDACTED]"`
#### Scenario: query / body redaction by exact field name
- **WHEN** `redactPayload({ query: { token: "a", safe: "v" }, body: { password: "p", email: "e" } }, defaultRules)` is called
- **THEN** `query.token === "[REDACTED]"`, `query.safe === "v"`, `body.password === "[REDACTED]"`, `body.email === "e"`
**Verified by:** `src/redact/redact.test.ts::"case-insensitive header redaction"` AND `src/redact/redact.test.ts::"query and body redaction by exact field name"` AND `src/redact/redact.test.ts::"default rules cover the canonical secret-bearing names"`
---
### Requirement: Every AUDIT_PLUGIN_* code is thrown from a production path
`AUDIT_PLUGIN_CODES` SHALL define exactly four codes (`INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SINK_INIT_FAILED`, `SINK_WRITE_FAILED`). Every code SHALL be raised by at least one production call site.
**Verified by:** `src/api-plugin-audit.spec-conformance.test.ts::"every AUDIT_PLUGIN_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry instruments emit per middleware operation
The package SHALL register: counter `audit.requests_recorded_total{outcome,sampled}`, counter `audit.requests_excluded_total`, counter `audit.sink_failures_total`, histogram `audit.middleware_duration_ms`. Every middleware execution SHALL emit either the recorded counter (with the `sampled` flag) or the excluded counter, plus the histogram observation.
#### Scenario: counter and histogram emit per recorded request
- **WHEN** a request flows through the middleware against a real in-memory `MeterProvider`
- **THEN** `audit.requests_recorded_total{outcome="success",sampled="true"}` has at least one observation AND `audit.middleware_duration_ms` has at least one observation
#### Scenario: excluded counter increments for health checks
- **WHEN** a request to `/health` flows through the middleware
- **THEN** `audit.requests_excluded_total` has at least one observation AND `audit.requests_recorded_total` does not increment for that request
**Verified by:** `src/otel/instruments.test.ts::"counter and histogram emit per recorded request"` AND `src/otel/instruments.test.ts::"excluded counter increments for health checks"` AND `src/otel/instruments.test.ts::"sink failures counter increments on sink rejection"`
---
### Requirement: Server-tier package shape, full plug-and-play
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module (10 subpaths). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. An integration test SHALL exercise: real `@suaveplan/api`-style host + this plugin + a sample route, asserting (a) the sink receives an audit event with redacted secrets, (b) `principal_id`, `tenant_id`, `status`, `duration_ms` are populated correctly, (c) explicit `c.get("audit").log(...)` flows through.
#### Scenario: full integration boot
- **WHEN** `tests/integration/full-boot.test.ts` boots a real `OpenAPIHono` + `PluginRegistry` + `createAuditPlugin({ sink: memoryAdapter, secretKey, redactQueryParams: ["token"] })` + a sample route that sets `user`/`tenant` and calls `c.get("audit").log(...)`
- **THEN** the memory sink contains the implicit request event AND the explicit handler event, with `details.query.token === "[REDACTED]"`, `actor.userId === "u1"`, `tenantId === "t1"`, `details.status === 200`, `details.duration_ms >= 0`
**Verified by:** `tests/integration/full-boot.test.ts::"real api + plugin + sample route records redacted audit with principal and tenant"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,195 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-auth (add-api-plugin-auth)
**Package**: `@suaveplan/api-plugin-auth`
**Status**: ACCEPTED
## ADDED Requirements
### Requirement: createAuthPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createAuthPlugin(options): PluginDescriptor<AuthPluginConfig>` that validates `options` against `AuthPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `AuthError(AUTH_INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "auth"`, the resolved `configSchema`, and the 4-phase lifecycle hooks (`onRegister`, `onInit`, `onReady`, `onClose`).
#### Scenario: factory produces a valid descriptor
- **WHEN** `createAuthPlugin({ betterAuth: { /* valid */ } })` is called
- **THEN** the returned descriptor satisfies `PluginDescriptor<AuthPluginConfig>` with `name === "auth"` and all 4 lifecycle hooks defined
#### Scenario: invalid options raise AUTH_INVALID_INPUT
- **WHEN** `createAuthPlugin({} as never)` is called (missing `betterAuth`)
- **THEN** `AuthError(AUTH_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createAuthPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise AUTH_INVALID_INPUT"`
---
### Requirement: onInit applies the bundled SurrealQL schema (plug-and-play point 1)
The descriptor's `onInit(ctx)` hook SHALL resolve `migrationRunner` (preferred) or `db` (fallback) from `ctx`'s `ServiceRegistry`. When `options.applySchemaOnInit === true` (default), it SHALL run `BETTER_AUTH_SURREAL_SCHEMA` (re-exported from `@suaveplan/better-auth-surrealdb/schema`) against the resolved adapter before constructing the Better-Auth instance. When neither service is registered AND `applySchemaOnInit === true`, `onInit` SHALL throw `AuthError(AUTH_SERVICE_NOT_AVAILABLE)` listing the missing service names. When `applySchemaOnInit === false`, the schema apply step SHALL be skipped (operator manages migrations externally).
#### Scenario: schema applied via migration runner when registered
- **WHEN** `migrationRunner` is in `ServiceRegistry` and the plugin initializes
- **THEN** `migrationRunner.run([{ name: "better-auth", content: BETTER_AUTH_SURREAL_SCHEMA, order: 0 }])` is awaited successfully AND Better-Auth construction follows
#### Scenario: schema applied via db fallback when no runner
- **WHEN** only `db` (SurrealAdapter) is registered and the plugin initializes
- **THEN** `applyBetterAuthSchema(db)` is awaited successfully before Better-Auth construction
#### Scenario: applySchemaOnInit: false skips the step
- **WHEN** `createAuthPlugin({ betterAuth, applySchemaOnInit: false })` initializes with NEITHER service registered
- **THEN** init completes without raising (Better-Auth construction still requires `db`; if that's absent the error is `AUTH_SERVICE_NOT_AVAILABLE`)
**Verified by:** `src/plugin/on-init.test.ts::"applies schema via migrationRunner when registered"` AND `src/plugin/on-init.test.ts::"applies schema via db fallback when no runner"` AND `src/plugin/on-init.test.ts::"applySchemaOnInit: false skips schema apply"` AND `src/plugin/on-init.test.ts::"missing services raise AUTH_SERVICE_NOT_AVAILABLE"`
---
### Requirement: onInit registers routes via definePluginRoutes (plug-and-play point 3)
After schema apply and Better-Auth construction, `onInit` SHALL resolve `openAPIRegistry` from `ServiceRegistry` and call `definePluginRoutes(buildAuthRoutes(auth), { app: ctx.app, registry: openAPIRegistry, pluginName: "auth" })` so the host's unified OpenAPI 3.1 document at `/openapi.json` includes every auth route with Zod-typed request/response schemas. Routes mount at `options.routePrefix` (default `/auth`). When `openAPIRegistry` is absent, `onInit` SHALL log a warning and mount routes via Hono directly (OpenAPI doc will not include them, but the runtime surface still works).
#### Scenario: routes registered in both Hono and OpenAPIRegistry
- **WHEN** an `OpenAPIRegistry` is in `ServiceRegistry` and the plugin initializes
- **THEN** the resulting `registry.getRoutesByPlugin("auth")` array contains every route from `buildAuthRoutes` AND every route is also reachable via the Hono app
#### Scenario: routePrefix collision raises AUTH_ROUTE_PREFIX_COLLISION
- **WHEN** another plugin has already registered routes under the same prefix and the plugin initializes
- **THEN** `AuthError(AUTH_ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix` set to the offending value
**Verified by:** `src/plugin/on-init.test.ts::"routes registered in both Hono and OpenAPIRegistry"` AND `src/plugin/on-init.test.ts::"routePrefix collision raises AUTH_ROUTE_PREFIX_COLLISION"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes auth routes with Zod-typed schemas"`
---
### Requirement: Session middleware sets typed c.get("user") on every request
`onInit` SHALL register a Hono middleware (via `ctx.app.use("*", ...)`) that on every request: (a) calls `auth.api.getSession(c.req.raw)`, (b) maps the result via `options.principalMapping ?? mapBetterAuthUserToPrincipal` to a `Principal`, (c) calls `c.set("user", principal)` and `c.set("session", session)`. Failures during session resolution SHALL set `c.set("user", null)` AND raise `AuthError(AUTH_SESSION_RESOLVE_FAILED)` only at the route level via `requireAuth` (silent at the middleware level — so anonymous routes still work). The package SHALL augment Hono's `ContextVariableMap` with `user: Principal | null` and `session: Session | null`.
#### Scenario: anonymous request has c.get("user") === null
- **WHEN** a request with no auth cookie/header reaches a handler
- **THEN** `c.get("user")` returns `null` AND the handler runs (no global 401)
#### Scenario: authenticated request has typed Principal
- **WHEN** a request with a valid Better-Auth cookie reaches a handler
- **THEN** `c.get("user")` returns a `Principal` with `id`, `email`, `emailVerified`, `roles`
**Verified by:** `src/middleware/session.test.ts::"anonymous request has null user"` AND `src/middleware/session.test.ts::"authenticated request has typed Principal"` AND `src/middleware/session.spec-conformance.test.ts::"ContextVariableMap augmented with user + session"`
---
### Requirement: requireAuth / requireRole / requireEmailVerified middleware
The package SHALL export three middleware factories:
- `requireAuth()` — reads `c.get("user")`; null → throws `AuthError(AUTH_UNAUTHENTICATED)` (statusCode 401). Returns the typed `Principal` to downstream handlers.
- `requireRole(...roles: string[])` — depends on `requireAuth` upstream. Reads `c.get("user")?.roles`; no intersection with `roles` → throws `AuthError(AUTH_INSUFFICIENT_ROLE)` (403) with `context.required: roles`.
- `requireEmailVerified()` — depends on `requireAuth` upstream. `c.get("user")?.emailVerified === false` → throws `AuthError(AUTH_EMAIL_NOT_VERIFIED)` (403).
#### Scenario: requireAuth on anonymous request raises 401
- **WHEN** `requireAuth()` runs on a request with `c.get("user") === null`
- **THEN** the middleware throws `AuthError(AUTH_UNAUTHENTICATED)` which the error-handler middleware translates to HTTP 401
#### Scenario: requireRole succeeds when intersection non-empty
- **WHEN** `requireRole("admin", "owner")` runs on a request with `principal.roles === ["owner"]`
- **THEN** the middleware passes (no throw)
#### Scenario: requireEmailVerified rejects when emailVerified is false
- **WHEN** `requireEmailVerified()` runs on a request whose principal has `emailVerified: false`
- **THEN** the middleware throws `AuthError(AUTH_EMAIL_NOT_VERIFIED)`
**Verified by:** `src/middleware/require-auth.test.ts::"raises AUTH_UNAUTHENTICATED on null user"` AND `src/middleware/require-auth.test.ts::"passes typed Principal downstream"` AND `src/middleware/require-role.test.ts::"intersects roles correctly"` AND `src/middleware/require-role.test.ts::"raises AUTH_INSUFFICIENT_ROLE on no intersection"` AND `src/middleware/require-email-verified.test.ts::"raises AUTH_EMAIL_NOT_VERIFIED when false"`
---
### Requirement: authService registered in ServiceRegistry with typed slot
`onInit` SHALL call `ctx.registerService("authService", { getCurrentPrincipal, getBetterAuthInstance, getSession })`. The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with `authService: AuthService` so downstream plugins can call `ctx.getService("authService")` with full type inference.
#### Scenario: downstream plugin reads authService with type safety
- **WHEN** a sibling plugin's `onInit(ctx)` calls `ctx.getService("authService")`
- **THEN** the return type is `AuthService | undefined` (no cast) AND the runtime value exposes the 3 methods
**Verified by:** `src/services/auth-service.spec-conformance.test.ts::"ServiceRegistry slot typed as AuthService"` AND `src/services/auth-service.test.ts::"getCurrentPrincipal returns c.get('user')"` AND `src/services/auth-service.test.ts::"getSession proxies to Better-Auth"`
---
### Requirement: 9 OpenAPI routes built with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)
The package SHALL export `buildAuthRoutes(auth: BetterAuthInstance): PluginRouteEntry[]` returning route entries for: `POST /sign-up`, `POST /sign-in`, `POST /sign-out`, `GET /session`, `POST /forgot-password`, `POST /reset-password`, `POST /verify-email`, `GET /oauth/:provider/start`, `GET /oauth/:provider/callback`. Each entry SHALL use `RouteBuilder` from `@suaveplan/api-openapi` (preserving Zod type inference through `.body/.query/.params/.response`). Body and query schemas SHALL be exported at `./schemas/routes` so downstream callers consume the same Zod types.
#### Scenario: every route has typed body/query/response
- **WHEN** `buildAuthRoutes(stubAuth)` returns its 9 entries and each is `.build()`-inspected
- **THEN** each `RouteConfig` has a `request.body.content["application/json"].schema` OR `request.query` referencing the matching Zod schema from `./schemas/routes`, AND the success `responses[200]` schema is non-empty
#### Scenario: registered routes appear in OpenAPI doc
- **WHEN** the plugin initializes against a real OpenAPIRegistry + Hono app and `/openapi.json` is GET'd
- **THEN** the response includes paths `/auth/sign-up`, `/auth/sign-in`, `/auth/session`, …, each with the expected method and schema references
**Verified by:** `src/routes/build-routes.test.ts::"every route has typed body/query/response"` AND `src/routes/build-routes.spec-conformance.test.ts::"expectTypeOf RouteBuilder preserves Zod inference end-to-end"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes auth routes with schemas"`
---
### Requirement: Zod schemas exported at ./schemas/* for cross-plugin consumption (plug-and-play point 2)
The package SHALL export Zod schemas at dedicated subpaths so sibling plugins consume the same boundary types: `./schemas/principal` (PrincipalSchema, SessionSchema, AccountSchema), `./schemas/options` (AuthPluginOptionsSchema), `./schemas/routes` (per-route request/response schemas). Every schema MUST have a corresponding TypeScript type via `z.infer` exported alongside.
#### Scenario: sibling plugin imports PrincipalSchema from ./schemas/principal
- **WHEN** a downstream plugin imports `@suaveplan/api-plugin-auth/schemas/principal` and calls `PrincipalSchema.parse(payload)`
- **THEN** the parse succeeds for valid principals and produces a typed `Principal` value
#### Scenario: every routes-schemas export has a sibling .md
- **WHEN** `tests/dep-graph.test.ts` enumerates `./schemas/routes`-tree files
- **THEN** every `<name>.ts` (excluding index.ts/test files) has a sibling `<name>.md`
**Verified by:** `src/schemas/principal.test.ts::"PrincipalSchema parses valid principal"` AND `src/schemas/options.test.ts::"AuthPluginOptionsSchema rejects missing betterAuth"` AND `tests/dep-graph.test.ts::"every schemas/* .ts has a sibling .md"`
---
### Requirement: Every AUTH_* code is thrown from a production path
`AUTH_CODES` SHALL define exactly 10 codes (`AUTH_UNAUTHENTICATED`, `AUTH_INSUFFICIENT_ROLE`, `AUTH_EMAIL_NOT_VERIFIED`, `AUTH_SESSION_RESOLVE_FAILED`, `AUTH_PRINCIPAL_MAPPING_FAILED`, `AUTH_BETTER_AUTH_INIT_FAILED`, `AUTH_INVALID_INPUT`, `AUTH_ROUTE_PREFIX_COLLISION`, `AUTH_SCHEMA_APPLY_FAILED`, `AUTH_SERVICE_NOT_AVAILABLE`). Every code SHALL be raised by ≥1 production call site.
**Verified by:** `src/api-plugin-auth.spec-conformance.test.ts::"every AUTH_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry instruments emit per operation
The package SHALL register counters `auth.sessions_resolved_total{outcome}`, `auth.role_check_total{outcome}`, `auth.signups_total`, `auth.signins_total{outcome}` and histogram `auth.session_resolve_duration_ms`. Every middleware + route emits both regardless of success/failure.
#### Scenario: counter + histogram emit per session resolve
- **WHEN** the session middleware runs against a real in-memory `MeterProvider`
- **THEN** both `auth.sessions_resolved_total` AND `auth.session_resolve_duration_ms` have observations
**Verified by:** `src/otel/instruments.test.ts::"counter + histogram emit per session resolve"` AND `src/otel/instruments.test.ts::"role-check counter emits on requireRole"`
---
### Requirement: Server-tier package shape, full 3-way plug-and-play
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module (15 subpaths covering `./plugin/plugin`, `./middleware/*`, `./principal/principal`, `./services/auth-service`, `./schemas/principal`, `./schemas/options`, `./schemas/routes`, `./routes/routes`, `./schema/schema`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise: (a) plugin registered in a real `PluginRegistry`, (b) `.surql` schema applied via real `@suaveplan/api-migrations` against testcontainer, (c) routes registered in real `OpenAPIRegistry`, (d) full sign-up → sign-in → /session → sign-out flow.
#### Scenario: full integration boot
- **WHEN** `tests/integration/full-boot.test.ts` constructs a real `PluginRegistry` with `createAuthPlugin({ betterAuth })`, real `MigrationRunner` against SurrealDB testcontainer, real `OpenAPIRegistry`, then drives a sign-up → sign-in → GET /session → sign-out roundtrip
- **THEN** every step succeeds, the schema is applied (idempotent on second `start()`), and `GET /openapi.json` returns a doc containing the 9 auth routes
**Verified by:** `tests/integration/full-boot.test.ts::"full sign-up/sign-in/session/sign-out roundtrip against real SurrealDB"` AND `tests/integration/full-boot.test.ts::"schema apply is idempotent across restarts"` AND `tests/integration/openapi-doc.test.ts::"served doc lists all 9 auth routes"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,298 @@
# `api-plugin-campaigns` capability
## ADDED Requirements
### Requirement: Package shape and tier
The package `@suaveplan/api-plugin-campaigns` SHALL be a server-tier Hono +
OpenAPI plugin published under the genesis monorepo at
`packages/api-stack/api/api-plugin-campaigns/`, version `0.1.0`,
`"sideEffects": false`.
#### Scenario: Workspace discovery
- **GIVEN** a `bunx turbo --filter=@suaveplan/api-plugin-campaigns` invocation
- **THEN** the workspace SHALL resolve to
`packages/api-stack/api/api-plugin-campaigns/`.
### Requirement: Error namespace `CAMPAIGNS_API.*`
The package SHALL export a `CAMPAIGNS_API_CODES` registry built via
`defineCodes` from `@suaveplan/error`, and a `CampaignsApiError` subclass of
`SuaveplanError` with an explicit public constructor delegating to `super`.
#### Scenario: Code namespacing
- **WHEN** any entry of `CAMPAIGNS_API_CODES` is read
- **THEN** the value SHALL begin with the prefix `"CAMPAIGNS_API."`.
### Requirement: OTel instrument bundle
The package SHALL bind an OpenTelemetry meter under
`"@suaveplan/api-plugin-campaigns"` exposing at minimum a request-count
counter and a request-duration histogram, with a cached factory function and
a reset hook for tests.
#### Scenario: Lazy binding
- **WHEN** `getCampaignsApiInstruments()` is called after a fresh
`MeterProvider` is installed and `resetCampaignsApiInstruments()` has been
invoked
- **THEN** the returned bundle SHALL be bound to the new provider.
### Requirement: Plugin factory
The package SHALL export `createCampaignsPlugin(options)` returning a
`PluginDescriptor` from `@suaveplan/api-plugin` with `name === "campaigns"`,
`version === "0.1.0"`, and `dependencies` including at minimum
`"database"`, `"auth"`, and `"contacts"`.
#### Scenario: Option validation
- **WHEN** `createCampaignsPlugin(options)` is called with options that fail
the Zod options schema
- **THEN** the function SHALL throw `CampaignsApiError` carrying code
`CAMPAIGNS_API.INVALID_OPTIONS` with the original `ZodError` attached as
`cause`.
### Requirement: Chunked migration discipline
The migration SHALL ship in three chunks (foundation A, repositories B,
routes C). At every chunk boundary the package SHALL pass lint, typecheck,
test, and 100/100/100/100 coverage on the modules shipped to date.
#### Scenario: Chunk-A `onInit` is a no-op placeholder
- **GIVEN** the Chunk-A milestone
- **WHEN** the plugin is registered on a host
- **THEN** `onInit` SHALL complete without registering repositories or routes,
and that no-op behaviour SHALL be documented in `plugin.md` as the
Chunk-A-correct state.
### Requirement: Campaign repository (Chunk B)
The package SHALL export a `SurrealCampaignRepository` class implementing the
`CampaignStore` contract from `@suaveplan/campaigns`. The repository SHALL
issue every database call through `SurrealAdapter.query<T>(sql, params)`
using positional `$p0`/`$p1`/... placeholders per the genesis Surreal idiom,
and SHALL wrap every adapter exception in `CampaignsApiError` with one of
`CAMPAIGNS_API.DB_UNAVAILABLE`, `CAMPAIGNS_API.CAMPAIGN_NOT_FOUND`, or
`CAMPAIGNS_API.INVALID_INPUT`.
**Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/repositories/campaign/campaign.test.ts`
(24 cases covering INSERT, SELECT-by-id, UPDATE, DELETE, listing with paging,
`updateStats` MERGE, `updateStatus` SET, schema validation, custom table
overrides, and adapter-error wrapping).
#### Scenario: Positional SurQL binding
- **WHEN** `SurrealCampaignRepository.create(campaign)` runs
- **THEN** the resulting SurQL SHALL contain `INSERT INTO campaign`, the
placeholder list SHALL use `$p0`/`$p1`/... in registration order, and the
campaign's `tenantId` SHALL appear at `params[1]`.
#### Scenario: Adapter-error wrapping
- **GIVEN** the adapter throws on a SELECT
- **WHEN** `getById("c1")` runs
- **THEN** the call SHALL throw `CampaignsApiError` carrying
`CAMPAIGNS_API.DB_UNAVAILABLE` with the original error attached as `cause`.
#### Scenario: Missing-row semantics for updateStats
- **GIVEN** the SurrealDB UPDATE returns no rows
- **WHEN** `updateStats(id, stats)` runs
- **THEN** the call SHALL throw `CampaignsApiError` carrying
`CAMPAIGNS_API.CAMPAIGN_NOT_FOUND` rather than a generic `Error`.
### Requirement: Campaign suppression store (Chunk B)
The package SHALL export a local `CampaignSuppressionStore` interface and a
`SurrealCampaignSuppressionRepository` implementation, plus a reference
`InMemoryCampaignSuppressionStore`. The local interface SHALL be tenant +
channel scoped — the `(tenantId, channel, channelValue)` triple is the
natural primary key — and the package SHALL NOT depend on
`@suaveplan/suppression`, which is email-only and therefore not a fit for
the campaigns plugin's multi-channel domain.
**Verified by:**
`packages/api-stack/api/api-plugin-campaigns/src/repositories/suppression/suppression.test.ts`
(SurrealDB implementation) and
`packages/api-stack/api/api-plugin-campaigns/src/repositories/suppression/suppression-memory.test.ts`
(in-memory reference) — together covering `add`, `remove`, `check`, `list`,
`count`, `removeByChannelValue`, duplicate detection, tenant guards, and
schema validation across both backends.
#### Scenario: Channel-scoped duplicate detection
- **GIVEN** an entry exists for `(t1, email, user@example.com)`
- **WHEN** `add({ tenantId: "t1", channel: "email", channelValue: "user@example.com", reason: "bounce" })` runs
- **THEN** the call SHALL throw `CampaignsApiError` carrying
`CAMPAIGNS_API.DUPLICATE_SUPPRESSION`, AND a subsequent
`add({ tenantId: "t1", channel: "sms", channelValue: "user@example.com", reason: "bounce" })`
SHALL succeed (different channel, same value).
#### Scenario: Empty tenant rejection
- **WHEN** any read method (`check`, `list`, `count`,
`removeByChannelValue`) is invoked with `tenantId === ""`
- **THEN** the call SHALL throw `CampaignsApiError` carrying
`CAMPAIGNS_API.TENANT_REQUIRED`.
### Requirement: Error namespace extension (Chunk B)
The Chunk-B repositories SHALL extend `CAMPAIGNS_API_CODES` additively with
six new codes: `CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`,
`INVALID_INPUT`, `DB_UNAVAILABLE`, `DUPLICATE_SUPPRESSION`,
`TENANT_REQUIRED`. The registry MUST remain a single `defineCodes` call —
no parallel namespace.
**Verified by:**
`packages/api-stack/api/api-plugin-campaigns/src/error-codes/error-codes.test.ts`
asserts the full code key set and that every new code begins with
`"CAMPAIGNS_API."`.
#### Scenario: Single registry, additive extension
- **WHEN** any new code is added in Chunks B or C
- **THEN** the code SHALL appear inside the existing
`defineCodes("CAMPAIGNS_API", [...])` call, AND
`Object.keys(CAMPAIGNS_API_CODES)` SHALL equal the union of every
registered key — no second registry exists.
### Requirement: Campaign route surface (Chunk C)
The package SHALL expose a `buildCampaignRoutes` aggregator returning a
readonly array of `PluginRouteEntry` values mounting the 12 canonical
`/campaigns/*` endpoints (list, create, get, update, delete, send, pause,
cancel, duplicate, test, stats, events) in a deterministic, documented
order. Every CRUD handler SHALL enforce tenant scoping through
`requireTenant`, cross-tenant lookups SHALL surface as
`CAMPAIGNS_API.CAMPAIGN_NOT_FOUND` (HTTP 404), and Zod validation
failures on request bodies SHALL surface as
`CAMPAIGNS_API.INVALID_INPUT` (HTTP 400). Action routes
(`/send`, `/pause`, `/cancel`) SHALL drive transitions through
`CampaignStateMachine` from `@suaveplan/campaigns`; invalid transitions
SHALL surface as `CAMPAIGNS_API.INVALID_STATE_TRANSITION` (HTTP 409).
Reporting routes (`/stats`, `/events`) SHALL consume the
`deliveryEventStore` context slot.
**Verified by:**
`packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/crud-routes.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/action-routes.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/build-routes.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/schemas.test.ts`
(80+ cases together covering happy paths, 401/404/409/400/500 surfaces,
state-machine transitions, duplicate semantics, delivery-event
reporting, and canonical-order assertions).
#### Scenario: Invalid state transition
- **GIVEN** a campaign in status `completed`
- **WHEN** `POST /campaigns/:id/send` runs
- **THEN** the response SHALL be HTTP 409 carrying body
`{ code: "CAMPAIGNS_API.INVALID_STATE_TRANSITION", ... }`.
#### Scenario: Cross-tenant lookup leak prevention
- **GIVEN** a campaign owned by tenant `other`
- **WHEN** the caller authenticated as tenant `t` issues
`GET /campaigns/:id`
- **THEN** the response SHALL be HTTP 404 carrying body
`{ code: "CAMPAIGNS_API.CAMPAIGN_NOT_FOUND", ... }` — identical to the
response for a non-existent id, so no information about cross-tenant
campaigns leaks.
### Requirement: Suppression route surface (Chunk C)
The package SHALL expose a `buildSuppressionRoutes` aggregator returning
the 4 canonical `/suppression/*` endpoints in canonical order (list,
check, create, delete). Every handler SHALL enforce `requireTenant`
before touching the suppression store. The `GET /suppression/check`
endpoint SHALL accept `channel` and `channelValue` as required query
parameters and return `{ suppressed: boolean, entry: ... | null }` so
sender pipelines can short-circuit deliveries with a single read.
`DELETE /suppression/:id` SHALL return HTTP 404 carrying
`CAMPAIGNS_API.SUPPRESSION_NOT_FOUND` when the entry does not exist.
**Verified by:**
`packages/api-stack/api/api-plugin-campaigns/src/routes/suppression/routes.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/suppression/schemas.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/suppression/build-routes.test.ts`
(20+ cases covering list/check/add/delete happy paths, 401/400/404/500
surfaces, and canonical-order assertions).
#### Scenario: Point-lookup suppression check
- **GIVEN** an entry exists for `(t1, email, user@example.com)`
- **WHEN** `GET /suppression/check?channel=email&channelValue=user@example.com`
runs
- **THEN** the response SHALL be HTTP 200 with body
`{ data: { suppressed: true, entry: { ... } } }`.
### Requirement: Webhook route surface (Chunk C)
The package SHALL expose a `buildWebhookRoutes` aggregator returning the
2 canonical `/webhooks/*` endpoints (delivery ingest and unsubscribe
token decoder). `POST /webhooks/delivery` SHALL persist the event via
the `deliveryEventStore` and additionally insert a
`CampaignSuppressionEntry` when the event type is `unsubscribed`,
`bounced`, or `complained`. The fan-out SHALL be idempotent — a
duplicate event SHALL NOT raise `DUPLICATE_SUPPRESSION` at the route
layer. Zod-validation failure on the delivery body SHALL surface as
`CAMPAIGNS_API.WEBHOOK_REJECTED`; token decode failure on the
unsubscribe path SHALL surface as
`CAMPAIGNS_API.INVALID_UNSUBSCRIBE_TOKEN`.
**Verified by:**
`packages/api-stack/api/api-plugin-campaigns/src/routes/webhooks/routes.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/webhooks/schemas.test.ts`,
`packages/api-stack/api/api-plugin-campaigns/src/routes/webhooks/build-routes.test.ts`
(20+ cases covering the three negative-event paths, idempotent retries,
token-decode failure modes, and registry-less fallback).
#### Scenario: Negative-event auto-suppression
- **GIVEN** a `POST /webhooks/delivery` with body
`{ messageId: "m1", eventType: "unsubscribed", contactId: "u1", channel: "email" }`
- **WHEN** the request completes
- **THEN** the response SHALL be HTTP 200 with `data.suppressed === true`,
AND `campaignSuppressionStore.check("t", "email", "u1")` SHALL return a
non-null entry with `reason === "unsubscribe"`.
### Requirement: Plugin onInit wiring (Chunk C)
The plugin's `onInit` SHALL resolve `app` and `db` from the host
`ServiceRegistry`, construct the SurrealDB-backed `CampaignStore`,
`CampaignSuppressionStore`, and `DeliveryEventStore` implementations,
register them on the registry plus the Hono `ContextVariableMap` via a
single mount-pattern middleware, and mount every route through
`definePluginRoutes` when an `openAPIRegistry` slot is available.
Missing `app` or `db` slots SHALL raise
`CAMPAIGNS_API.SERVICE_NOT_AVAILABLE`. A registered registry with
pre-existing routes under the configured prefix SHALL raise
`CAMPAIGNS_API.ROUTE_PREFIX_COLLISION`.
**Verified by:**
`packages/api-stack/api/api-plugin-campaigns/src/plugin/plugin.test.ts`
(18 cases covering happy-path onInit, both SERVICE_NOT_AVAILABLE
branches, registry warning fallback, ROUTE_PREFIX_COLLISION re-run,
custom-prefix routing, and idGenerator/clock default exercise) and
`packages/api-stack/api/api-plugin-campaigns/tests/integration/plugin-integration.test.ts`
(8 end-to-end cases against a real `OpenAPIHono` host with in-memory
repositories — no mocks).
#### Scenario: Missing db service
- **GIVEN** a host with `app` registered but no `db` slot
- **WHEN** the campaigns plugin's `onInit` runs
- **THEN** the call SHALL throw `CampaignsApiError` carrying
`CAMPAIGNS_API.SERVICE_NOT_AVAILABLE` with `context.missing === ["db"]`.
#### Scenario: Route-prefix collision
- **GIVEN** an `OpenAPIRegistry` already carrying routes under
`/campaigns`
- **WHEN** the campaigns plugin's `onInit` runs against that registry
- **THEN** the call SHALL throw `CampaignsApiError` carrying
`CAMPAIGNS_API.ROUTE_PREFIX_COLLISION`.

View File

@@ -0,0 +1,201 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-cms
**Package**: `@suaveplan/api-plugin-cms`
**Status**: SHIPPED
**Source change**: `openspec/archive/2026-05-19-add-api-plugin-cms/`
> Every requirement carries a `**Verified by:**` line pointing at the concrete test that pins it.
## Requirements
### Requirement: Content-model lifecycle
The package SHALL expose a `CmsEngine` class that manages `ContentModel` records in an in-memory `Map`, supporting `createModel`, `getModel`, `listModels`, and `deleteModel`. Deleting a model with existing content entries SHALL raise `CmsError(CMS.MODEL_IN_USE)`; looking up an unknown model SHALL raise `CmsError(CMS.MODEL_NOT_FOUND)`. Successful `createModel` SHALL emit a `model.created` event on the engine's bound `EventBus<CmsEventMap>`.
#### Scenario: createModel registers and emits
- **WHEN** the caller invokes `engine.createModel({ name, slug, contentType, fields })`
- **THEN** the returned `ContentModel` has `id`, `createdAt`, and `updatedAt` populated and the `model.created` event is emitted to every listener
#### Scenario: deleteModel rejects models with entries
- **WHEN** the caller invokes `engine.deleteModel(id)` for a model that has at least one `ContentEntry`
- **THEN** `CmsError(CMS.MODEL_IN_USE)` is thrown and the model remains in `listModels()`
#### Scenario: getModel rejects unknown ids
- **WHEN** the caller invokes `engine.getModel("unknown")`
- **THEN** `CmsError(CMS.MODEL_NOT_FOUND)` is thrown with `context.modelId === "unknown"`
**Verified by:** `src/engine/cms-engine.test.ts::"createModel registers and emits model.created"` AND `src/engine/cms-engine.test.ts::"deleteModel rejects when content entries exist"` AND `src/engine/cms-engine.test.ts::"getModel throws MODEL_NOT_FOUND for unknown id"`
---
### Requirement: Content-entry CRUD and querying
The package SHALL expose `createContent`, `getContent`, `queryContent`, `updateContent`, and `deleteContent` on `CmsEngine`. `createContent` SHALL run `validateContentData` against the parent model and raise `CmsError(CMS.VALIDATION_FAILED)` on any error. Slug uniqueness within the same `modelId` SHALL be enforced — collisions raise `CmsError(CMS.DUPLICATE_SLUG)`. `queryContent` SHALL filter by `modelId`, `status`, `locale`, and case-insensitive `search` over `title`/`slug`; sort by an arbitrary field with `asc`/`desc`; and slice by `offset`/`limit`.
#### Scenario: createContent validates and persists
- **WHEN** the caller invokes `engine.createContent({ modelId, title, data, ... })` with data passing model validation
- **THEN** a `ContentEntry` is returned with `status: "draft"`, `version: 1`, generated `slug` when omitted, and the `content.created` event is emitted
#### Scenario: createContent rejects invalid data
- **WHEN** the caller invokes `engine.createContent(...)` with data violating a `required`/`minLength`/`pattern` rule on the model
- **THEN** `CmsError(CMS.VALIDATION_FAILED)` is thrown carrying the joined error list in `message`
#### Scenario: queryContent supports the documented filter/sort/page surface
- **WHEN** the caller invokes `engine.queryContent({ modelId, status, locale, search, sortBy, sortOrder, offset, limit })`
- **THEN** results reflect every filter, are sorted, and are paged in the same order as the upstream implementation
#### Scenario: updateContent honours slug uniqueness
- **WHEN** the caller invokes `engine.updateContent(id, { slug: someOtherEntrysSlug })` within the same `modelId`
- **THEN** `CmsError(CMS.DUPLICATE_SLUG)` is thrown and the existing entry remains unchanged
**Verified by:** `src/engine/cms-engine.test.ts::"createContent persists and emits content.created"` AND `src/engine/cms-engine.test.ts::"createContent rejects data failing model validation"` AND `src/engine/cms-engine.test.ts::"queryContent applies modelId/status/locale/search/sort/page filters"` AND `src/engine/cms-engine.test.ts::"updateContent rejects invalid data + duplicate slugs within the same model"`
---
### Requirement: Status-transition guard
The package SHALL expose `VALID_STATUS_TRANSITIONS` (a `Record<ContentStatus, readonly ContentStatus[]>`) and a `CmsEngine.transitionStatus(id, target)` method that consults the table; disallowed transitions SHALL raise `CmsError(CMS.INVALID_TRANSITION)`. On transition the entry's `version` SHALL increment and the matching event (`content.published` / `content.drafted` / `content.archived` / fallback `content.updated`) SHALL be emitted. Transitions to `published` SHALL stamp `publishedAt` with the current clock reading.
#### Scenario: legal transition advances state
- **WHEN** the caller invokes `engine.transitionStatus(id, "published")` on a `draft` entry
- **THEN** the entry's `status` is now `"published"`, `version` is incremented, `publishedAt` is set, and `content.published` is emitted
#### Scenario: illegal transition raises
- **WHEN** the caller invokes `engine.transitionStatus(id, "published")` on an `archived` entry (not in `VALID_STATUS_TRANSITIONS["archived"]`)
- **THEN** `CmsError(CMS.INVALID_TRANSITION)` is thrown and the entry is unchanged
**Verified by:** `src/engine/cms-engine.test.ts::"transitionStatus advances draft to published and emits content.published"` AND `src/engine/cms-engine.test.ts::"transitionStatus rejects an illegal target"`
---
### Requirement: Versioned history
The package SHALL retain a `ContentVersion[]` per entry, appended on every `createContent`, `updateContent`, and `transitionStatus` call. `engine.getVersions(entryId)` SHALL return the full history; deleting the entry SHALL drop its versions.
#### Scenario: versions accumulate
- **WHEN** the caller creates an entry then runs two updates and one transition
- **THEN** `engine.getVersions(id)` returns four versions with monotonically increasing `version` fields
**Verified by:** `src/engine/cms-engine.test.ts::"getVersions returns one entry per mutation"`
---
### Requirement: Error-code registry
The package SHALL define exactly seven stable error codes via `defineCodes("CMS", [...])`: `CONTENT_NOT_FOUND`, `MODEL_NOT_FOUND`, `INVALID_TRANSITION`, `VALIDATION_FAILED`, `DUPLICATE_SLUG`, `MODEL_IN_USE`, `INVALID_INPUT`. Each code SHALL be raised by at least one production code path. The `CmsError` class SHALL extend `SuaveplanError<CmsErrorCode>` and expose a `cmsCode` readonly mirror for `instanceof` narrowing.
#### Scenario: every CMS.* code is raised from a production path
- **WHEN** the spec-conformance audit scans `src/engine/`, `src/error-codes/`, and `src/plugin/`
- **THEN** every value of `CMS_CODES` appears in at least one `new CmsError(...)` or `CMS_CODES.<NAME>` call site
#### Scenario: CmsError preserves cause chain
- **WHEN** a factory helper is invoked with a `cause`
- **THEN** the resulting `CmsError`'s `.cause` references the supplied error and `instanceof CmsError` narrows `.code` to `CmsErrorCode`
**Verified by:** `src/api-plugin-cms.spec-conformance.test.ts::"every CMS.* code is thrown from a production path"` AND `src/error-codes/error-codes.test.ts::"every factory raises the matching CMS_CODES.* value"` AND `src/error-codes/error-codes.test.ts::"CmsError preserves cause chain and narrows code"`
---
### Requirement: RFC 7807 problem+json rendering
The package SHALL export `respondProblemJson(res, err)` and `formatProblemJson(err)` plus the `PROBLEM_JSON_CONTENT_TYPE` constant. `CmsError` instances SHALL map to specific HTTP statuses (404 for `CONTENT_NOT_FOUND`/`MODEL_NOT_FOUND`; 409 for `INVALID_TRANSITION`/`DUPLICATE_SLUG`/`MODEL_IN_USE`; 400 for `VALIDATION_FAILED`; 500 for `INVALID_INPUT`). Generic `SuaveplanError` instances SHALL fall through to 500; non-error throwables SHALL render `{ type: "about:blank", status: 500, title: "Internal Server Error" }`.
#### Scenario: CmsError renders the documented status
- **WHEN** the caller invokes `formatProblemJson(contentNotFound("x"))`
- **THEN** the returned body has `status: 404`, `code: "CMS.CONTENT_NOT_FOUND"`, and `type: "urn:suaveplan:error:CMS.CONTENT_NOT_FOUND"`
#### Scenario: respondProblemJson writes header + body to the response
- **WHEN** the caller invokes `respondProblemJson(res, modelInUse("m1"))` against a `HttpResponseLike` double
- **THEN** the content-type header is `application/problem+json`, the body is the JSON-stringified `ProblemDetails`, and the status is 409
**Verified by:** `src/routes/problem-json.test.ts::"formatProblemJson maps every CmsError code to the documented HTTP status"` AND `src/routes/problem-json.test.ts::"respondProblemJson sets header and body on the response"` AND `src/routes/problem-json.test.ts::"formatProblemJson falls back to 500 for generic SuaveplanError and unknown throwables"`
---
### Requirement: Content-model types, slug utility, and validator
The package SHALL export the full type surface (`ContentStatus`, `ContentType`, `FieldType`, `ContentField`, `FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`, `CreateContentInput`, `UpdateContentInput`, `ContentQuery`, `CmsEventMap`). It SHALL export `generateSlug` (lowercases, strips non-alphanumerics, collapses whitespace and hyphens) and `validateContentData` (runs `required` plus per-field `minLength`/`maxLength`/`min`/`max`/`pattern`/`custom` rules and returns a `string[]` of error messages).
#### Scenario: generateSlug normalises arbitrary titles
- **WHEN** the caller invokes `generateSlug(" Hello, World! ")`
- **THEN** the result is `"hello-world"`
#### Scenario: validateContentData reports every failing rule
- **WHEN** the caller invokes `validateContentData(model, data)` on data that violates two rules
- **THEN** the result is a 2-length array of human-readable messages
**Verified by:** `src/types/cms.types.test.ts::"generateSlug normalises whitespace, punctuation, and case"` AND `src/types/cms.types.test.ts::"validateContentData enforces required + every FieldValidation type"` AND `src/types/cms.types.test.ts::"VALID_STATUS_TRANSITIONS lists every legal edge"`
---
### Requirement: Plugin factory and ServiceRegistry slot
The package SHALL export `createCmsPlugin(options)` that returns a `PluginDescriptor<CmsPluginConfig>` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. Options SHALL be Zod-validated; invalid input raises `CmsError(CMS.INVALID_INPUT)` carrying the `ZodError`. `onInit` SHALL construct a `CmsEngine` using the supplied `Clock`/`IdGenerator` (or genesis defaults) and register it under the `cms` slot of the host `ServiceRegistry`. `onClose` SHALL drop the engine reference.
#### Scenario: createCmsPlugin returns a usable descriptor
- **WHEN** the caller invokes `createCmsPlugin({})`
- **THEN** the returned descriptor has `name === "cms"`, `version === "0.1.0"`, and exposes `onInit` / `onClose` functions
#### Scenario: invalid options raise CMS.INVALID_INPUT
- **WHEN** the caller invokes `createCmsPlugin(42 as never)`
- **THEN** `CmsError(CMS.INVALID_INPUT)` is thrown with the `ZodError` attached as `cause`
#### Scenario: onInit registers the engine
- **WHEN** the registry executes the plugin's `onInit` against a `PluginContext`
- **THEN** `ctx.getService("cms")` returns a `CmsEngine` instance
**Verified by:** `src/plugin/plugin.test.ts::"createCmsPlugin returns a valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise CMS.INVALID_INPUT"` AND `src/plugin/plugin.test.ts::"onInit registers the CmsEngine under the cms slot"`
---
### Requirement: OpenTelemetry instruments
The package SHALL expose a `CmsInstruments` bundle resolved lazily via `getCmsInstruments()` exposing seven counters (`cms.models_created_total`, `cms.content_created_total`, `cms.content_updated_total`, `cms.content_published_total`, `cms.content_archived_total`, `cms.content_deleted_total`, `cms.validation_failures_total`). Every counter SHALL be incremented from the engine method that produces the corresponding event. A `resetCmsInstruments()` test helper SHALL be exported.
#### Scenario: counters fire on engine mutations
- **WHEN** the test installs an in-memory `MeterProvider`, calls `resetCmsInstruments()`, creates a model + entry, publishes the entry, and flushes
- **THEN** the exported metrics include `cms.models_created_total == 1`, `cms.content_created_total == 1`, and `cms.content_published_total == 1`
**Verified by:** `src/otel/instruments.test.ts::"counters fire on engine mutations and are observable via in-memory MeterProvider"`
---
### Requirement: Barrel side-effect registration and dependency invariants
The package barrel (`src/index.ts`) SHALL side-effect-import `./services/registry-augmentation.js` so consumers of `import "@suaveplan/api-plugin-cms"` see the `cms` `ServiceRegistry` slot augmentation without further wiring. The package SHALL declare exactly the runtime workspace dependencies enumerated in `package.json` (`@suaveplan/api-plugin`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`) and nothing more.
#### Scenario: barrel side-effects fire on load
- **WHEN** any consumer imports `@suaveplan/api-plugin-cms`
- **THEN** `CMS_CODES.CONTENT_NOT_FOUND === "CMS.CONTENT_NOT_FOUND"` is observable without an additional import of `./error-codes/error-codes.js`
#### Scenario: dependency set is pinned
- **WHEN** the dep-graph test runs against `package.json`
- **THEN** the runtime workspace deps are exactly `@suaveplan/api-plugin`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
**Verified by:** `src/api-plugin-cms.spec-conformance.test.ts::"barrel side-effect imports the registry augmentation"` AND `tests/dep-graph.test.ts::"runtime workspace dependencies are pinned"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,150 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-contacts
**Package**: `@suaveplan/api-plugin-contacts`
**Status**: ACTIVE (folded 2026-05-20 from migrate-api-plugin-contacts)
## ADDED Requirements
### Requirement: Plugin factory with validated configuration
The package SHALL expose `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig>` that Zod-validates `options` at construction time and returns a typed `PluginDescriptor` declaring `dependencies: ["database", "auth"]` so the host plugin manager topo-sorts after `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`. The descriptor SHALL surface the same identifiers as the upstream `1.0.0` API (`name: "contacts"`, `tags: ["contacts", "groups", "audience"]`) but ship at `0.1.0`.
#### Scenario: factory rejects invalid options
- **WHEN** caller passes `maxGroupDepth: -1`
- **THEN** factory throws `ContactsApiError` with code `CONTACTS_API.INVALID_INPUT` carrying the `ZodError` as `cause`
#### Scenario: descriptor exposes defaults
- **WHEN** factory is called with no arguments
- **THEN** descriptor `defaults` equals `{ routePrefix: "/", maxImportBatchSize: 1000, maxGroupDepth: 10, enforceGroupDepth: true }`
**Verified by:** `src/plugin/plugin.test.ts::"createContactsPlugin returns a typed descriptor with default config"` AND `src/plugin/plugin.test.ts::"createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"descriptor advertises name, dependencies, version, tags"`
---
### Requirement: Stable error-code registry
The package SHALL export `CONTACTS_API_CODES` (built with `defineCodes("CONTACTS_API", […])`) and a `ContactsApiError extends SuaveplanError<ContactsApiErrorCode>` class. Every failure path in the package SHALL raise a `ContactsApiError` carrying one of the 12 stable codes.
#### Scenario: every registered code is thrown from production code
- **WHEN** static analysis greps `throw new ContactsApiError\(CONTACTS_API_CODES\.` across `src/**/*.ts` (excluding tests)
- **THEN** every member of `CONTACTS_API_CODES` appears at least once
#### Scenario: error class preserves cause chain
- **WHEN** repository raises with `cause: new Error("boom")`
- **THEN** `error.cause instanceof Error` and `error.code === "CONTACTS_API.<code>"`
**Verified by:** `src/error-codes/error-codes.test.ts::"every code is a member of the CONTACTS_API namespace"` AND `src/error-codes/error-codes.test.ts::"ContactsApiError preserves cause"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"every error code is thrown by at least one production path"`
---
### Requirement: OpenTelemetry instrument bundle emitted from production paths
The package SHALL export `getContactsApiInstruments(): ContactsApiInstruments` and `resetContactsApiInstruments()` and SHALL emit `contacts_api.contact_writes_total`, `contacts_api.contact_reads_total`, `contacts_api.group_writes_total`, `contacts_api.membership_writes_total`, `contacts_api.import_duration_ms` (histogram) and `contacts_api.merge_duration_ms` (histogram) from the corresponding route handlers.
#### Scenario: in-memory MeterProvider receives counter increments
- **WHEN** integration test installs an `InMemoryMetricExporter`-backed `MeterProvider`, calls `resetContactsApiInstruments`, then triggers `POST /contacts` once
- **THEN** the collected metrics include `contacts_api.contact_writes_total{outcome="success"}=1`
**Verified by:** `src/otel/instruments.test.ts::"getContactsApiInstruments returns the six-instrument bundle"` AND `src/otel/instruments.test.ts::"resetContactsApiInstruments rebinds against a freshly installed MeterProvider"` AND `tests/integration/full-boot.test.ts::"emits contacts_api.contact_writes_total on POST /contacts"`
---
### Requirement: SurrealDB-backed store implementations
The package SHALL export `SurrealContactRepository`, `SurrealGroupRepository`, and `SurrealMembershipRepository` implementing the genesis `@suaveplan/contacts` `ContactStore`, `GroupStore`, and `MembershipStore` interfaces exactly (including `generateId()`). Repositories SHALL accept a connected `SurrealAdapter` via the constructor and SHALL NOT open or close it.
#### Scenario: ContactStore roundtrip
- **WHEN** integration test calls `repo.create({ tenantId, displayName, channels })` then `repo.getById(id)`
- **THEN** the returned `Contact` matches the created shape with `createdAt` / `updatedAt` as `Date` instances
#### Scenario: GroupStore enforces maxGroupDepth
- **WHEN** factory was called with `maxGroupDepth: 2, enforceGroupDepth: true`, the repository builds depth-0 → depth-1 → depth-2 groups, and then attempts to create a depth-3 child
- **THEN** the create call throws `CONTACTS_API.GROUP_DEPTH_EXCEEDED`
#### Scenario: MembershipStore add is idempotent through repository
- **WHEN** caller invokes the `POST /groups/:id/members` route twice with the same contact id
- **THEN** the second call returns `{ added: 0 }` (no `MEMBERSHIP_EXISTS` thrown — uniqueness handled at the route boundary by an existence pre-check)
**Verified by:** `src/repositories/contact-repository.test.ts::"create + getById roundtrip against fake adapter"` AND `src/repositories/group-repository.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED past maxGroupDepth"` AND `src/repositories/membership-repository.test.ts::"add raises MEMBERSHIP_EXISTS on duplicate"` AND `tests/integration/full-boot.test.ts::"contact roundtrip against real SurrealDB"` AND `tests/integration/group-depth.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED when enforceGroupDepth is on"`
---
### Requirement: Typed routes with Zod boundaries
The package SHALL mount `/contacts` and `/groups` route trees on the host `OpenAPIHono` via `@hono/zod-openapi`. Every route SHALL declare request and response Zod schemas and SHALL read the current `Principal` from `c.get("user")` (populated upstream by `@suaveplan/api-plugin-auth`'s `requireAuth()` middleware). Routes SHALL NOT use `as any` casts.
#### Scenario: unauthenticated request is rejected
- **WHEN** test posts to `/contacts` without an auth session
- **THEN** response is 401 with `{ success: false, error: "Unauthorized" }`
#### Scenario: principal without tenantId is rejected
- **WHEN** test posts to `/contacts` with a `Principal` lacking `tenantId`
- **THEN** response is 401 with `{ success: false, error: "Unauthorized" }`
#### Scenario: well-formed contact create returns 201 + body
- **WHEN** authenticated request posts a valid `CreateContactSchema` body
- **THEN** response is 201 with `{ success: true, data: { id, … } }`
**Verified by:** `src/routes/contacts/build-routes.test.ts::"POST /contacts rejects requests without a tenantId"` AND `src/routes/contacts/build-routes.test.ts::"POST /contacts returns 201 on a valid body"` AND `src/routes/groups/build-routes.test.ts::"GET /groups/:id/tree returns a typed GroupTreeNode"` AND `tests/integration/full-boot.test.ts::"full contact + group + membership roundtrip"`
---
### Requirement: Bulk-import endpoint honours maxImportBatchSize
The package SHALL expose `POST /contacts/import` whose body is `{ contacts: CreateContactSchema[], skipDuplicates: boolean }` capped at `maxImportBatchSize`. The route SHALL Zod-reject batches larger than the cap with 400 and SHALL return `{ imported, skipped, errors: [{ index, error }] }` with `errors` populated per failing row.
#### Scenario: oversized batch is rejected at the schema boundary
- **WHEN** caller posts a batch of length `maxImportBatchSize + 1`
- **THEN** response is 400 (Zod boundary) and no rows are created
#### Scenario: per-row errors reported with original index
- **WHEN** caller posts a batch where row index 2 violates a database constraint
- **THEN** response body `data.errors` contains `{ index: 2, error: <message> }` and `data.imported` equals the count of successful rows
**Verified by:** `src/routes/contacts/build-routes.test.ts::"POST /contacts/import rejects oversized batches at the schema boundary"` AND `tests/integration/bulk-import.test.ts::"per-row errors are reported with the original index against real SurrealDB"`
---
### Requirement: Typed Hono ContextVariableMap and ServiceRegistry augmentation
The package SHALL augment Hono's `ContextVariableMap` with `contactRepository`, `groupRepository`, `membershipRepository`, and `eventBus` slots, and SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface with `contactRepository`, `groupRepository`, `membershipRepository` slots so sibling plugins can resolve them via `ctx.getService(…)` without a cast.
#### Scenario: type-level access compiles without `as`
- **WHEN** consumer writes `const repo = c.get("contactRepository")` inside a Hono handler in TypeScript
- **THEN** `repo` is typed as `SurrealContactRepository | undefined`
**Verified by:** `src/context/context.test.ts::"ContextVariableMap exposes contactRepository slot typed as SurrealContactRepository"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"ServiceRegistry augments contactRepository, groupRepository, membershipRepository slots"`
---
### Requirement: Schema migration via bundled SurrealQL
The package SHALL ship `surql/tables/{contact,contact_group,group_membership}.surql` and SHALL apply them on `onInit` either via the host `MigrationRunner` (when present in `ServiceRegistry`) or via direct `SurrealAdapter.execute` calls otherwise. Errors at apply time SHALL throw `CONTACTS_API.SCHEMA_APPLY_FAILED` with the underlying cause attached.
#### Scenario: schema apply succeeds against fresh SurrealDB
- **WHEN** plugin `onInit` runs against a fresh Surreal namespace/database
- **THEN** the three tables exist and a subsequent `INFO FOR TABLE contact` succeeds
#### Scenario: schema apply failure is wrapped
- **WHEN** the apply step throws (simulated by closing the adapter before `onInit`)
- **THEN** the `onInit` promise rejects with `CONTACTS_API.SCHEMA_APPLY_FAILED`
**Verified by:** `src/plugin/on-init.test.ts::"onInit raises SCHEMA_APPLY_FAILED when execute throws"` AND `tests/integration/full-boot.test.ts::"onInit applies surql tables against real SurrealDB"`

View File

@@ -0,0 +1,233 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-database (add-api-plugin-database)
**Package**: `@suaveplan/api-plugin-database`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Stable error-code registry
The package SHALL register a `DATABASE` namespace via `defineCodes` and expose a `DatabaseError` class extending `SuaveplanError<DatabaseErrorCode>`. Every code SHALL be thrown from at least one production call site.
#### Scenario: Code list is stable
- **WHEN** consumers import `DATABASE_CODES`
- **THEN** the export contains exactly: `CONNECTION_FAILED`, `POOL_EXHAUSTED`, `POOL_CLOSED`, `QUERY_FAILED`, `MIGRATION_FAILED`, `LOCK_CONTENDED`, `TENANT_INVALID`, `INVALID_CONFIG`
#### Scenario: Every code is reachable
- **WHEN** the package's source tree is scanned
- **THEN** every code is thrown from a non-test production path
**Verified by:** `src/error-codes/error-codes.test.ts::"defineCodes produces the expected DATABASE_* namespace"` AND `src/api-plugin-database.spec-conformance.test.ts::"every DATABASE_* code is thrown from a production path"`
---
### Requirement: Connection pool over SurrealAdapter
The package SHALL expose `ConnectionPool` that owns a bounded set of `SurrealAdapter` instances. `acquire()` SHALL return an idle adapter, lazily grow to `max`, or queue on a waiter when at capacity. `release(adapter)` SHALL hand the adapter to the next waiter or return it to the idle set. `acquireTimeoutMs` SHALL reject queued waiters with `DATABASE.POOL_EXHAUSTED`. `close()` SHALL drain idle adapters and reject every pending waiter with `DATABASE.POOL_CLOSED`.
#### Scenario: Acquire under capacity returns existing idle adapter
- **WHEN** the pool has idle adapters and a caller invokes `acquire()`
- **THEN** the call resolves with that adapter and `size().idle` decreases by 1
#### Scenario: Acquire above min lazily creates up to max
- **WHEN** the pool currently has `< max` adapters and no idle ones
- **THEN** `acquire()` constructs a new adapter, connects it, marks it in-use, and `size().total` increases by 1
#### Scenario: Acquire at max times out
- **WHEN** all `max` adapters are in use and a caller invokes `acquire()` without a free slot within `acquireTimeoutMs`
- **THEN** the call rejects with `DatabaseError(DATABASE.POOL_EXHAUSTED)`
#### Scenario: Release wakes a waiter
- **WHEN** there is a queued waiter and a caller invokes `release(adapter)`
- **THEN** the waiter's promise resolves with that adapter
#### Scenario: Close drains and rejects pending acquires
- **WHEN** waiters are queued and the pool is closed
- **THEN** every waiter rejects with `DatabaseError(DATABASE.POOL_CLOSED)` and `acquire()` afterwards rejects with the same code
**Verified by:** `src/pool/pool.test.ts::"acquire returns an idle adapter"` AND `src/pool/pool.test.ts::"acquire lazily grows up to max"` AND `src/pool/pool.test.ts::"acquire rejects with POOL_EXHAUSTED past acquireTimeoutMs"` AND `src/pool/pool.test.ts::"release hands the adapter to a queued waiter"` AND `src/pool/pool.test.ts::"close drains adapters and rejects pending waiters"`
---
### Requirement: Retry with exponential backoff
The package SHALL expose `withRetry(fn, options)` that re-invokes `fn` up to `maxAttempts` times, sleeping `min(baseBackoffMs * 2^(attempt-1), maxBackoffMs)` (optionally jittered) between attempts. The caller MAY provide `isRetryable(err, attempt)` to bail out early.
#### Scenario: First-attempt success returns immediately
- **WHEN** `fn` resolves on its first call
- **THEN** `withRetry` returns the value without sleeping
#### Scenario: Retries on transient failure
- **WHEN** `fn` rejects twice and then resolves
- **THEN** `withRetry` invokes `fn` three times and returns the eventual value
#### Scenario: Throws the last error when exhausted
- **WHEN** `fn` rejects every attempt up to `maxAttempts`
- **THEN** `withRetry` throws the last error
#### Scenario: Non-retryable predicate bails out
- **WHEN** `isRetryable` returns false
- **THEN** `withRetry` throws immediately without further attempts
**Verified by:** `src/retry/retry.test.ts::"first-attempt success returns the value"` AND `src/retry/retry.test.ts::"retries on transient failure"` AND `src/retry/retry.test.ts::"throws after maxAttempts exhausted"` AND `src/retry/retry.test.ts::"isRetryable false bails out early"`
---
### Requirement: Migration runner with advisory lock
The package SHALL run `.surql` migrations through `@suaveplan/api-migrations`' `createSurrealqlAdapter` + `createMigrationRunner` while holding an advisory lock backed by a SurrealDB row whose `expiresAt` acts as a lease. A second concurrent runner SHALL observe the lock and skip work. Lease expiry SHALL allow takeover by another holder.
#### Scenario: Advisory lock excludes a concurrent holder
- **WHEN** holder A holds an unexpired lock and holder B attempts `acquireLock`
- **THEN** B's `acquireLock` returns `false`
#### Scenario: Expired lock is taken over
- **WHEN** the recorded lease is in the past and a new holder attempts `acquireLock`
- **THEN** the new holder records its own lease and `acquireLock` returns `true`
#### Scenario: Runner applies pending migrations under the lock
- **WHEN** the `.surql` directory contains pending files and `runMigrations` is called
- **THEN** every pending file is applied in lexicographic order, the ledger records each, and `applied[].name` lists them in order
#### Scenario: Lock contention surfaces as DATABASE.LOCK_CONTENDED
- **WHEN** the lock is held by another holder and `runMigrations` cannot acquire
- **THEN** the result includes the discovered files under `skipped` and no migrations are applied
**Verified by:** `src/migrations/lock.test.ts::"acquireLock returns false when another holder is active"` AND `src/migrations/lock.test.ts::"expired lock can be taken over"` AND `src/migrations/runner.test.ts::"applies pending migrations in order and records ledger entries"` AND `src/migrations/runner.test.ts::"returns skipped list when lock cannot be acquired"`
---
### Requirement: Tenancy provisioning callables
The package SHALL expose `provisionTenant(adapter, config, tenantId)` and `decommissionTenant(adapter, config, tenantId)` callables that honour all three strategies (`shared`, `namespace-per-tenant`, `database-per-tenant`). Tenant IDs SHALL be validated against `^[A-Za-z0-9_-]{1,64}$`; invalid IDs SHALL throw `DatabaseError(DATABASE.TENANT_INVALID)`.
#### Scenario: Shared strategy adds the discriminator field
- **WHEN** the config strategy is `shared` and `provisionTenant` is called
- **THEN** the adapter receives a `DEFINE FIELD … tenant_id` query
#### Scenario: Namespace strategy provisions a tenant namespace
- **WHEN** the config strategy is `namespace-per-tenant`
- **THEN** the adapter receives a `DEFINE NAMESPACE` query with the encoded tenant id
#### Scenario: Database strategy provisions a tenant database
- **WHEN** the config strategy is `database-per-tenant`
- **THEN** the adapter receives a `DEFINE DATABASE` query with the encoded tenant id
#### Scenario: Invalid tenant id throws TENANT_INVALID
- **WHEN** the supplied tenant id violates the validation regex
- **THEN** `provisionTenant` throws `DatabaseError(DATABASE.TENANT_INVALID)` before any query is issued
**Verified by:** `src/tenancy/tenancy.test.ts::"shared strategy adds the tenant_id discriminator"` AND `src/tenancy/tenancy.test.ts::"namespace-per-tenant strategy issues DEFINE NAMESPACE"` AND `src/tenancy/tenancy.test.ts::"database-per-tenant strategy issues DEFINE DATABASE"` AND `src/tenancy/tenancy.test.ts::"invalid tenant id throws TENANT_INVALID"`
---
### Requirement: RFC 7807 problem-json renderer
The package SHALL expose `respondProblemJson(ctx, err)` that delegates to `@suaveplan/error`'s formatter and pins the response content-type to `application/problem+json`.
#### Scenario: DatabaseError is rendered as problem+json
- **WHEN** `respondProblemJson` is invoked with a `DatabaseError`
- **THEN** the response carries `application/problem+json` and a body whose `type` is the registered URI for that code
#### Scenario: Unknown throwables fall through to 500
- **WHEN** `respondProblemJson` is invoked with a plain `Error`
- **THEN** the response status is 500 and the body is RFC 7807-shaped
**Verified by:** `src/routes/problem-json.test.ts::"DatabaseError renders as problem+json with the registered status"` AND `src/routes/problem-json.test.ts::"unknown throwables fall through to a 500 problem+json"`
---
### Requirement: Zod-validated configuration boundary
The package SHALL validate every public options bundle through Zod schemas at the call boundary. Invalid input SHALL throw `DatabaseError(DATABASE.INVALID_CONFIG)` with the `ZodError` carried as `cause`.
#### Scenario: Canonical input parses
- **WHEN** `DatabasePluginOptionsSchema.parse({ url: "ws://x/rpc" })` is invoked
- **THEN** every default field is populated (pool, retry, tenancy, migrations, query)
#### Scenario: Out-of-range pool max is rejected
- **WHEN** `createDatabasePlugin({ pool: { max: 0 } as never })` is invoked
- **THEN** the factory throws `DatabaseError(DATABASE.INVALID_CONFIG)` with the underlying `ZodError`
**Verified by:** `src/config/config-schema.test.ts::"parses canonical input with defaults"` AND `src/config/config-schema.test.ts::"rejects out-of-range pool max"` AND `src/plugin/plugin.test.ts::"createDatabasePlugin throws INVALID_CONFIG for bad options"`
---
### Requirement: OTel instrumentation
The package SHALL emit OpenTelemetry instruments through `@opentelemetry/api`'s global meter. The bundle SHALL include: `db.pool.checkouts_total` counter, `db.pool.checkins_total` counter, `db.pool.saturation` up-down-counter, `db.query.duration_ms` histogram (labelled by `{ outcome: "success" | "error" }`), and a delegation to `@suaveplan/api-migrations`' `migrations.applied_total`. A `resetDatabaseInstruments()` helper SHALL clear the cached bundle for test reuse.
#### Scenario: Every instrument emits against an in-memory MeterProvider
- **WHEN** the test setup installs an in-memory `MeterProvider`, calls every recorder, and force-flushes
- **THEN** the exporter snapshot contains a metric with each declared name
#### Scenario: Bundle is cached across calls
- **WHEN** `getDatabaseInstruments()` is invoked twice
- **THEN** the second call returns the same object as the first (referential equality)
**Verified by:** `src/otel/instruments.test.ts::"every instrument emits against the in-memory exporter"` AND `src/otel/instruments.test.ts::"caches the bundle across calls"`
---
### Requirement: Plugin factory wires pool, migrations, and service registry
The package SHALL expose `createDatabasePlugin(options?)` returning a `PluginDescriptor<DatabasePluginConfig>`. `onInit` SHALL construct the pool, eagerly create `min` adapters, and register the pool's primary adapter under `ServiceRegistry["db"]`. `onReady` SHALL run migrations under advisory lock when `migrations.autoRun` is `true`. `onClose` SHALL drain the pool. Construction-time Zod validation MUST gate every option.
#### Scenario: Plugin descriptor exposes the canonical contract
- **WHEN** consumers call `createDatabasePlugin({ url: "ws://x/rpc" })`
- **THEN** the descriptor has `name === "database"`, `version === "0.1.0"`, four lifecycle hooks defined, and `defaults` is the merged Zod-validated config
#### Scenario: onInit registers the db service
- **WHEN** the plugin lifecycle runs through `onInit` against a stub adapter factory
- **THEN** `ctx.getService("db")` returns the pooled `DatabaseAdapter`
#### Scenario: onClose drains the pool
- **WHEN** the plugin's `onClose` runs
- **THEN** the pool's `close()` is invoked and the adapter is released
**Verified by:** `src/plugin/plugin.test.ts::"descriptor exposes the canonical name/version/hooks"` AND `src/plugin/plugin.test.ts::"onInit constructs the pool and registers the db service"` AND `src/plugin/plugin.test.ts::"onClose drains the pool"`
---
### Requirement: Real SurrealDB integration
The package SHALL ship at least one integration test against a real SurrealDB instance via `@suaveplan/testing-integration-surrealdb`. The test SHALL exercise the full lifecycle (pool acquire → run migrations → tenant provision → release → close).
#### Scenario: Full roundtrip against a real container
- **WHEN** the integration test boots a real SurrealDB container, drives `createDatabasePlugin` through its lifecycle, and provisions a tenant
- **THEN** every step succeeds and the pool reports zero leaks at shutdown
**Verified by:** `tests/integration/full-boot.test.ts::"full pool + migration + tenancy roundtrip against real SurrealDB"`

View File

@@ -0,0 +1,214 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-event-bus
**Package**: `@suaveplan/api-plugin-event-bus`
**Status**: SHIPPED (0.1.0, 2026-05-19, folded from add-api-plugin-event-bus)
> Every requirement carries a `**Verified by:**` line pointing at the concrete test that pins it.
## Requirements
### Requirement: createEventBusPlugin factory validates options and returns a typed PluginDescriptor
The package SHALL export `createEventBusPlugin(options)` which Zod-validates `options` against `EventBusPluginOptionsSchema` and returns a `PluginDescriptor<EventBusPluginConfig>` produced by `definePlugin` from `@suaveplan/api-plugin`. Invalid input SHALL throw `EventBusError(EVENT_BUS.INVALID_INPUT)` carrying the underlying `ZodError` as `cause`.
#### Scenario: valid options resolve defaults and return a descriptor
- **WHEN** `createEventBusPlugin({})` is called with no overrides
- **THEN** the returned descriptor has `name: "event-bus"`, `version: "0.1.0"`, `defaults.routePrefix === "/event-bus"`, `defaults.mountRoutes === true`, `defaults.requireAuthForRoutes === true`, `defaults.tenantIsolation === true`, `defaults.backpressure.policy === "drop-oldest"`, and `defaults.backpressure.capacity === 1024`
#### Scenario: invalid options reject at the factory boundary
- **WHEN** `createEventBusPlugin({ routePrefix: "no-leading-slash" })` is called
- **THEN** the call throws `EventBusError` whose `code === "EVENT_BUS.INVALID_INPUT"` and `cause` is a `ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createEventBusPlugin resolves defaults and returns a typed PluginDescriptor"` AND `src/plugin/plugin.test.ts::"createEventBusPlugin throws EVENT_BUS.INVALID_INPUT for invalid options"`
---
### Requirement: onInit executes the canonical 6-step lifecycle
The package's plugin descriptor SHALL execute, in order, an `onInit(ctx)` flow that (1) resolves `app` and (optionally) `openAPIRegistry` from the host `ServiceRegistry`, (2) constructs an `EventBus` from `@suaveplan/events` and applies configured interceptors / backpressure / idempotency, (3) registers `eventBusService` in the `ServiceRegistry`, (4) auto-prefixes topics with `tenant:<id>:` whenever publishes/subscribes happen through the scoped handle, (5) installs middleware that puts a `ScopedEventBusHandle` under `c.set("eventBus", ...)`, and (6) mounts the optional HTTP routes under `routePrefix` when `mountRoutes !== false`.
#### Scenario: onInit registers the service slot
- **WHEN** `onInit(ctx)` runs against a context carrying the `app` slot
- **THEN** `ctx.getService("eventBusService")` returns an `EventBusService` object exposing `publish`, `subscribe`, `unsubscribe`, `listSubscriptions`, and `getRawBus`
#### Scenario: onInit installs the per-request middleware
- **WHEN** a request hits any path on the host app after `onInit` completes
- **THEN** the handler observes `c.get("eventBus")` typed as `ScopedEventBusHandle` with `publish`/`subscribe`/`unsubscribe`/`listSubscriptions` methods bound to the request's tenant (if any)
#### Scenario: onInit fails when the `app` slot is missing
- **WHEN** `onInit(ctx)` runs against a context with no `app` slot in `ServiceRegistry`
- **THEN** the call throws `EventBusError(EVENT_BUS.SERVICE_NOT_AVAILABLE)` with `context.missing` containing `"app"`
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers eventBusService in the ServiceRegistry"` AND `src/plugin/plugin.test.ts::"onInit installs request-bus middleware that exposes ScopedEventBusHandle"` AND `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE when the app slot is missing"`
---
### Requirement: Per-tenant topic scoping isolates publishes and subscribes
When `tenantIsolation` is enabled (the default), every `publish` and `subscribe` flowing through the scoped middleware SHALL prefix the topic with `tenant:<id>:` where `<id>` comes from `c.get("tenant")?.id`. A subscriber for tenant A SHALL NOT receive payloads published by tenant B for the same logical topic, and vice versa.
#### Scenario: tenant A publish does not reach tenant B subscriber
- **WHEN** tenant A's scoped handle publishes `"order.created"` and tenant B's scoped handle subscribes to `"order.created"`
- **THEN** tenant B's handler is invoked zero times and tenant A's handler (if subscribed) is invoked exactly once
#### Scenario: tenant-less request does not prefix topics
- **WHEN** a request without a `tenant` context publishes `"system.heartbeat"` through the scoped handle
- **THEN** the underlying bus emits `"system.heartbeat"` verbatim (no `tenant:` prefix)
#### Scenario: scopeTopic / unscopeTopic round-trip
- **WHEN** `scopeTopic("order.created", "t1")` is computed and then passed to `unscopeTopic(scoped, "t1")`
- **THEN** the result is `"order.created"`; calling `unscopeTopic(scoped, "t2")` returns `null`
**Verified by:** `src/topic/topic.test.ts::"scopeTopic prefixes with tenant id when tenantId is provided"` AND `src/topic/topic.test.ts::"unscopeTopic returns null for foreign tenant prefix"` AND `tests/integration/tenant-isolation.test.ts::"tenant A publish does not reach tenant B subscriber"`
---
### Requirement: Topic names are validated against EVENT_TOPIC_PATTERN
The package SHALL reject any `publish`/`subscribe` topic that fails the regex `/^[a-z0-9_\-:.]+$/` or exceeds the configured `maxTopicLength` (default 256). Validation SHALL apply both at the service layer (`eventBusService.publish`/`subscribe`) and at the HTTP route layer (`POST /event-bus/publish` body schema).
#### Scenario: invalid topic at the service layer
- **WHEN** `eventBusService.publish("Order Created", {})` is called
- **THEN** the call rejects with `EventBusError(EVENT_BUS.INVALID_TOPIC)` whose `context.topic === "Order Created"`
#### Scenario: topic exceeding maxTopicLength
- **WHEN** the plugin is configured with `maxTopicLength: 8` and `publish("a".repeat(9), {})` is called
- **THEN** the call rejects with `EventBusError(EVENT_BUS.INVALID_TOPIC)` whose `context.reason === "exceeds-max-length"`
**Verified by:** `src/services/event-bus-service.test.ts::"publish rejects with INVALID_TOPIC when topic violates pattern"` AND `src/services/event-bus-service.test.ts::"publish rejects with INVALID_TOPIC when topic exceeds maxTopicLength"`
---
### Requirement: Bounded-queue backpressure drops on overflow and increments the dropped counter
Every `publish` SHALL flow through a `BoundedQueue` from `@suaveplan/events` configured with `backpressure.capacity` and `backpressure.policy`. Under `drop-oldest` / `drop-newest`, an overflowing publish SHALL resolve with `PublishResult.dropped === true` and increment `event_bus.dropped_total{policy}` by 1. Under `policy: "throw"`, the publish SHALL reject with `EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`.
#### Scenario: drop-oldest overflow surfaces dropped flag and counter
- **WHEN** the plugin is configured with `backpressure: { capacity: 1, policy: "drop-oldest" }` and three sequential publishes occur before any drain
- **THEN** at least one published result carries `dropped === true` and `event_bus.dropped_total{policy="drop-oldest"}` is incremented at least once
#### Scenario: throw policy rejects on overflow
- **WHEN** the plugin is configured with `backpressure: { capacity: 1, policy: "throw" }` and a second concurrent publish overflows the queue
- **THEN** the second `publish` promise rejects with `EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`
**Verified by:** `src/services/event-bus-service.test.ts::"drop-oldest overflow returns dropped=true and increments the counter"` AND `src/services/event-bus-service.test.ts::"throw policy rejects with BACKPRESSURE_DROPPED on overflow"`
---
### Requirement: Idempotent subscriptions deliver duplicates exactly once
When a subscription is registered with `idempotencyKey` (or the plugin-level `idempotency.enabled` flag is true), repeated publishes carrying the same key SHALL invoke the handler exactly once. The `event_bus.duplicates_total` counter SHALL increment once per duplicate that was suppressed.
#### Scenario: duplicate publish with same idempotency key fires handler once
- **WHEN** the same payload is published twice with `idempotencyKey: "k1"` to a subscriber whose `idempotencyKey: (env) => env.idempotencyKey ?? env.id` was provided
- **THEN** the handler is invoked exactly once and `event_bus.duplicates_total` is incremented exactly once
**Verified by:** `src/services/event-bus-service.test.ts::"idempotent subscription fires handler once for duplicate publish"`
---
### Requirement: HTTP routes mount under routePrefix and are auth-gated by default
When `mountRoutes !== false`, the plugin SHALL register `POST /event-bus/publish`, `GET /event-bus/subscriptions`, and `DELETE /event-bus/subscriptions/:id` on the host Hono app and on the host's `OpenAPIRegistry` via `definePluginRoutes`. When `requireAuthForRoutes` is `true` (the default), the routes SHALL be gated by `requireAuth()` middleware imported from `@suaveplan/api-plugin-auth`. When a registered route already lives under `routePrefix`, the plugin SHALL throw `EventBusError(EVENT_BUS.ROUTE_PREFIX_COLLISION)`.
#### Scenario: mountRoutes false skips route registration
- **WHEN** `createEventBusPlugin({ mountRoutes: false })` runs through `onInit`
- **THEN** the host's `OpenAPIRegistry` has zero routes tagged `"event-bus"`
#### Scenario: route prefix collision throws
- **WHEN** the OpenAPI registry already has a `GET /event-bus/foo` route registered and `onInit` runs with the default `routePrefix`
- **THEN** the call throws `EventBusError(EVENT_BUS.ROUTE_PREFIX_COLLISION)` whose `context.prefix === "/event-bus"`
#### Scenario: publish route returns delivered + dropped on a real request
- **WHEN** a `POST /event-bus/publish` request arrives with body `{ topic: "order.created", payload: { id: "o1" } }` and one subscriber is registered for that topic
- **THEN** the response body satisfies `PublishResultSchema` with `delivered === 1` and `dropped === false`
**Verified by:** `src/routes/build-routes.test.ts::"mountRoutes=false skips route registration"` AND `src/plugin/plugin.test.ts::"onInit throws ROUTE_PREFIX_COLLISION when routes already occupy the prefix"` AND `tests/integration/http-routes.test.ts::"POST /event-bus/publish returns delivered + dropped"`
---
### Requirement: unsubscribe + listSubscriptions enforce per-tenant ownership
`eventBusService.unsubscribe(id)` SHALL return `false` (and `DELETE /event-bus/subscriptions/:id` SHALL return HTTP 404) when the supplied id is not owned by the calling tenant. `listSubscriptions(filter)` SHALL filter by the calling tenant when `tenantIsolation` is enabled and a tenant scope is supplied.
#### Scenario: tenant B cannot unsubscribe tenant A's subscription
- **WHEN** tenant A subscribes, obtains handle `id1`, and tenant B's scoped handle calls `unsubscribe(id1)`
- **THEN** the call returns `false` and tenant A's subscription remains active (a subsequent publish from tenant A still delivers)
#### Scenario: listSubscriptions filters to the calling tenant
- **WHEN** tenant A has 2 subscriptions and tenant B has 1, and tenant A's scoped handle calls `listSubscriptions()`
- **THEN** the returned array has length 2 and every record's `tenantId === "tenant-A"`
**Verified by:** `tests/integration/tenant-isolation.test.ts::"tenant B cannot unsubscribe tenant A's subscription"` AND `tests/integration/tenant-isolation.test.ts::"listSubscriptions filters by calling tenant"`
---
### Requirement: Every EventBusError code is reachable from production paths
The package SHALL declare the 8-code `EVENT_BUS_CODES` registry via `defineCodes("EVENT_BUS", [...])`. Every declared code SHALL be raised from at least one production code path and SHALL be exercised by at least one test in `src/`.
#### Scenario: registry exposes the expected 8 codes
- **WHEN** `EVENT_BUS_CODES` is enumerated
- **THEN** the object owns keys `INVALID_INPUT`, `INVALID_TOPIC`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `SUBSCRIPTION_NOT_FOUND`, `BACKPRESSURE_DROPPED`, `TENANT_MISMATCH`, and `PUBLISH_FAILED`
#### Scenario: every code is raised somewhere in src/
- **WHEN** the spec-conformance test greps `src/` for `EVENT_BUS_CODES.<code>` references
- **THEN** every code is referenced by at least one non-test file
**Verified by:** `src/error-codes/error-codes.test.ts::"registry exposes the 8 expected codes"` AND `src/api-plugin-event-bus.spec-conformance.test.ts::"every EVENT_BUS_CODES entry is raised from a production path"`
---
### Requirement: OTel instruments are emitted and assertable from production paths
The package SHALL emit, via `@opentelemetry/api`'s global `MeterProvider`, the counters `event_bus.published_total{outcome,topic_root}`, `event_bus.dropped_total{policy}`, `event_bus.duplicates_total`, and the up/down counter `event_bus.subscriptions_active`, plus the histogram `event_bus.publish_duration_ms`. All instruments SHALL be created lazily via a `getEventBusInstruments()` singleton that is reset between tests through `resetEventBusInstruments()`.
#### Scenario: published_total increments after a publish
- **WHEN** a publish flows through `eventBusService.publish("order.created", {})`
- **THEN** the in-memory `MeterProvider` records `event_bus.published_total{outcome="success",topic_root="order"}` incremented by 1
#### Scenario: subscriptions_active reflects active subscription count
- **WHEN** two subscriptions are added then one is removed
- **THEN** `event_bus.subscriptions_active` is at value 1
**Verified by:** `src/otel/instruments.test.ts::"published_total increments after a publish"` AND `src/otel/instruments.test.ts::"subscriptions_active tracks the live subscription count"`
---
### Requirement: Subpath exports enable feature-grained tree-shaking
The package's `exports` map SHALL list one entry per feature module so consumers can `import { ... } from "@suaveplan/api-plugin-event-bus/<feature>/<feature>"`. The exports list SHALL match the set in the proposal's "Subpath exports" section verbatim.
#### Scenario: every advertised subpath resolves to a real built artefact
- **WHEN** the `exports` map keys are enumerated and each `default` path is checked for existence after `bunx turbo build` completes
- **THEN** every default path resolves to a real file under `./dist/`
**Verified by:** `src/api-plugin-event-bus.spec-conformance.test.ts::"package.json exports list every feature subpath"`
---
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,446 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-events (add-api-plugin-events)
**Package**: `@suaveplan/api-plugin-events`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Plugin factory and lifecycle
The package SHALL export `createEventsPlugin(options?: EventsPluginConfigInput)` returning a `PluginDescriptor<EventsPluginConfig>` that mounts event-management routes against `@suaveplan/api`. The factory SHALL Zod-validate input via `EventsPluginOptionsSchema` at the boundary and throw `EventsPluginError(INVALID_CONFIG)` carrying the `ZodError` as `cause` on invalid input.
#### Scenario: valid options produce a descriptor
- **WHEN** `createEventsPlugin({ basePath: "/v1/events" })` is called
- **THEN** the returned descriptor has `name === "events"`, `dependencies: ["database"]`, and `defaults.basePath === "/v1/events"`
#### Scenario: invalid options fail fast
- **WHEN** `createEventsPlugin({ maxInvitesPerRequest: -1 })` is called
- **THEN** the call throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.INVALID_CONFIG` and `cause instanceof ZodError`
#### Scenario: onInit registers repositories
- **WHEN** the descriptor's `onInit(ctx)` runs with a real `db` service in the registry
- **THEN** `ctx.getService("eventRepository")` and `ctx.getService("rsvpRepository")` resolve to non-null repository instances
#### Scenario: onInit fails without database service
- **WHEN** `onInit(ctx)` runs with no `db` service registered
- **THEN** it throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE`
#### Scenario: onReady mounts routes under basePath
- **WHEN** `onReady(ctx)` runs after `onInit`
- **THEN** `ctx.getService("app").routes` contains entries whose `path` begins with the configured `basePath`
#### Scenario: onReady refuses to double-mount
- **WHEN** `onReady(ctx)` is invoked twice against the same app instance
- **THEN** the second call throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION`
#### Scenario: onClose clears repositories
- **WHEN** `onClose()` is invoked after a successful boot
- **THEN** subsequent lifecycle methods on the descriptor's `runtime` see cleared `eventRepository` / `rsvpRepository` state and the registry's slots are no longer accessed by the plugin
**Verified by:** `src/plugin/plugin.test.ts::"createEventsPlugin returns descriptor with defaults"` AND `src/plugin/plugin.test.ts::"createEventsPlugin throws INVALID_CONFIG for bad options"` AND `src/plugin/plugin.test.ts::"onInit registers eventRepository and rsvpRepository"` AND `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE without db"` AND `src/plugin/plugin.test.ts::"onReady mounts routes under basePath"` AND `src/plugin/plugin.test.ts::"onReady throws ROUTE_PREFIX_COLLISION on double-mount"` AND `src/plugin/plugin.test.ts::"onClose clears runtime state"`
---
### Requirement: Configuration schema
The package SHALL expose `EventsPluginOptionsSchema` (Zod) producing `EventsPluginConfig` with three fields: `basePath: string` (default `/events`), `enableNotifications: boolean` (default `false`), `maxInvitesPerRequest: integer between 1 and 100` (default `50`).
#### Scenario: defaults apply when input is empty
- **WHEN** `EventsPluginOptionsSchema.parse({})` is called
- **THEN** the returned object equals `{ basePath: "/events", enableNotifications: false, maxInvitesPerRequest: 50 }`
#### Scenario: out-of-range invite cap rejected
- **WHEN** `EventsPluginOptionsSchema.safeParse({ maxInvitesPerRequest: 0 })` is called
- **THEN** `success === false` and `error.issues[0].path` includes `maxInvitesPerRequest`
**Verified by:** `src/schemas/options.test.ts::"parses empty input to defaults"` AND `src/schemas/options.test.ts::"rejects out-of-range maxInvitesPerRequest"`
---
### Requirement: EventRepository CRUD
The package SHALL expose `EventRepository` implementing `IEventRepository` with `findAll(query?)`, `findById(id)`, `create(userId, data)`, `update(id, data)`, `delete(id)`, `isCreator(eventId, userId)`, and `updateAttendeeCount(eventId, status, increment)`. Every method SHALL accept user IDs with or without the `user:` prefix and event IDs with or without the `event:` prefix, normalising both forms internally.
#### Scenario: findAll applies status / upcoming / past filters
- **WHEN** `findAll({ status: 1, upcoming: true, limit: 10, offset: 5 })` is called
- **THEN** the underlying adapter receives a SELECT with `WHERE status = $p0 AND end_time > time::now()` and positional params `[1]` plus pagination clauses
#### Scenario: findById returns null when missing
- **WHEN** `findById("nonexistent")` runs and the adapter returns `[]`
- **THEN** the method returns `null`
#### Scenario: create raises FAILURE on empty response
- **WHEN** `create("user:1", data)` runs and the adapter returns `[]`
- **THEN** the method throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.EVENT_CREATE_FAILED`
#### Scenario: update emits only provided fields
- **WHEN** `update("1", { title: "X" })` is called
- **THEN** the SET clause includes `title = $p0` and does not include `description`, `start_time`, etc.
#### Scenario: isCreator handles RecordId / object / string shapes
- **WHEN** the adapter returns `{ creator: { tb: "user", id: "1" } }` and `isCreator("event:1", "user:1")` is called
- **THEN** the method returns `true`
#### Scenario: updateAttendeeCount no-ops for not_going
- **WHEN** `updateAttendeeCount("event:1", "not_going", 1)` is called
- **THEN** no adapter query runs
**Verified by:** `src/repositories/event-repository/event-repository.test.ts::"findAll applies status and upcoming filters"` AND `src/repositories/event-repository/event-repository.test.ts::"findById returns null when not found"` AND `src/repositories/event-repository/event-repository.test.ts::"create raises EVENT_CREATE_FAILED on empty response"` AND `src/repositories/event-repository/event-repository.test.ts::"update only emits provided fields"` AND `src/repositories/event-repository/event-repository.test.ts::"isCreator handles RecordId object"` AND `src/repositories/event-repository/event-repository.test.ts::"updateAttendeeCount no-ops for not_going"`
---
### Requirement: RsvpRepository surface
The package SHALL expose `RsvpRepository` implementing `IRsvpRepository` with `findByEventAndUser`, `findAttendees`, `upsert`, `delete`, `createInvitations`, and `countByStatus`. The repository SHALL idempotently skip existing invitations when `createInvitations` is called with a user that already has an RSVP row.
#### Scenario: upsert creates when no row exists
- **WHEN** `upsert("event:1", "user:1", { status: "going" })` runs against an empty table
- **THEN** the adapter receives a CREATE statement and the returned RSVP has `status === "going"`
#### Scenario: upsert updates when a row exists
- **WHEN** `upsert("event:1", "user:1", { status: "maybe" })` runs and a row already exists for the same (event, user)
- **THEN** the adapter receives an UPDATE statement instead of CREATE
#### Scenario: delete returns the prior status
- **WHEN** `delete("event:1", "user:1")` runs and the prior row had `status: "going"`
- **THEN** the method returns `"going"` and the row is deleted
#### Scenario: createInvitations skips existing rows
- **WHEN** `createInvitations("event:1", ["user:1", "user:2"])` runs and `user:1` already has an RSVP
- **THEN** the returned array has length 1 (only `user:2`)
#### Scenario: countByStatus returns 0 on empty
- **WHEN** `countByStatus("event:1", "going")` runs and the adapter returns `[]`
- **THEN** the method returns `0`
**Verified by:** `src/repositories/rsvp-repository/rsvp-repository.test.ts::"upsert creates new row when none exists"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"upsert updates when row exists"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"delete returns prior status"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"createInvitations skips existing rows"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"countByStatus returns 0 on empty result"`
---
### Requirement: Route surface (9 endpoints)
The package SHALL expose `createEventRoutes({ eventRepository, rsvpRepository, emitEvent? })` returning an `OpenAPIHono` instance with these routes (relative to the mount prefix): `GET /`, `GET /{id}`, `POST /`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/rsvp`, `DELETE /{id}/rsvp`, `GET /{id}/my-rsvp`, `GET /{id}/attendees`, `POST /{id}/invite`. Each route SHALL be wrapped with `@hono/zod-openapi` `createRoute`, validating request bodies / params / queries through the package's Zod schemas. Every route SHALL require an authenticated user (`c.get("user")`) and return `401` with `EVENTS_PLUGIN_CODES.UNAUTHENTICATED` when absent.
#### Scenario: unauthenticated request returns 401
- **WHEN** a request reaches `GET /` without `user` set in the Hono context
- **THEN** the response status is `401` and the body is `{ error: "Authentication required" }`
#### Scenario: list returns paginated events
- **WHEN** `GET /` runs with `user` set and `eventRepository.findAll` returns 2 events
- **THEN** the response is `200` and the body is `{ data: [event1, event2] }`
#### Scenario: get returns 404 when event missing
- **WHEN** `GET /missing-id` runs with `user` set and `findById` returns `null`
- **THEN** the response is `404` with body `{ error: "Event not found" }`
#### Scenario: create emits event and returns 201
- **WHEN** `POST /` runs with valid body and `user.id = "user:1"`
- **THEN** the response is `201`, the body is the created event, and `emitEvent` is called with `{ type: "event:created", payload: { eventId, creatorId: "user:1" } }`
#### Scenario: update requires creator
- **WHEN** `PUT /{id}` runs with a user who is not the event creator
- **THEN** the response is `403` with body `{ error: "Only event creator can update this event" }`
#### Scenario: delete by non-creator forbidden
- **WHEN** `DELETE /{id}` runs with a non-creator user
- **THEN** the response is `403`
#### Scenario: rsvp updates attendee counts
- **WHEN** `POST /{id}/rsvp` runs with `status: "going"` and no prior RSVP
- **THEN** `eventRepository.updateAttendeeCount` is called with `(id, "going", 1)` and `emitEvent` fires `rsvp:created`
#### Scenario: cancel-rsvp returns 404 when no prior RSVP
- **WHEN** `DELETE /{id}/rsvp` runs and the user has no RSVP row
- **THEN** the response is `404`
#### Scenario: attendees returns paginated list
- **WHEN** `GET /{id}/attendees` runs
- **THEN** the response is `200` with body `{ data: [...], total: <count> }`
#### Scenario: invite requires creator
- **WHEN** `POST /{id}/invite` runs with a non-creator
- **THEN** the response is `403`
#### Scenario: invite enforces maxInvitesPerRequest
- **WHEN** `POST /{id}/invite` is called with a `user_ids` array longer than `maxInvitesPerRequest`
- **THEN** validation fails at the Zod boundary with a `400`
**Verified by:** `src/routes/events-routes/events-routes.test.ts::"GET / returns 401 without user"` AND `src/routes/events-routes/events-routes.test.ts::"GET / returns paginated events"` AND `src/routes/events-routes/events-routes.test.ts::"GET /{id} returns 404 when missing"` AND `src/routes/events-routes/events-routes.test.ts::"POST / emits event:created and returns 201"` AND `src/routes/events-routes/events-routes.test.ts::"PUT /{id} returns 403 for non-creator"` AND `src/routes/events-routes/events-routes.test.ts::"DELETE /{id} returns 403 for non-creator"` AND `src/routes/events-routes/events-routes.test.ts::"POST /{id}/rsvp updates attendee count"` AND `src/routes/events-routes/events-routes.test.ts::"DELETE /{id}/rsvp returns 404 when none"` AND `src/routes/events-routes/events-routes.test.ts::"GET /{id}/attendees returns list"` AND `src/routes/events-routes/events-routes.test.ts::"POST /{id}/invite returns 403 for non-creator"` AND `src/schemas/rsvp-schemas.test.ts::"InviteUsersSchema enforces user_ids length"`
---
### Requirement: Error code registry
The package SHALL expose `EVENTS_PLUGIN_CODES = defineCodes("EVENTS_PLUGIN", [...])` covering 12 stable codes: `INVALID_CONFIG`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `EVENT_CREATE_FAILED`, `EVENT_UPDATE_FAILED`, `RSVP_CREATE_FAILED`, `RSVP_UPDATE_FAILED`, `RSVP_NOT_FOUND`, `EVENT_NOT_FOUND`, `UNAUTHENTICATED`, `FORBIDDEN`, `INVITE_LIMIT_EXCEEDED`. The namespace `"EVENTS_PLUGIN"` (not `"EVENTS"`) prevents collision with `@suaveplan/events`. Every code SHALL be thrown by at least one production path.
#### Scenario: every code is reachable
- **WHEN** the conformance test reflects over `EVENTS_PLUGIN_CODES` and grep-asserts each value appears in a `new EventsPluginError(<code>, ...)` call site under `src/`
- **THEN** every code resolves to ≥1 match
**Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"`
---
### Requirement: OTel instruments
The package SHALL emit 5 OpenTelemetry instruments via `metrics.getMeter("@suaveplan/api-plugin-events")`: `eventsCreatedTotal` (Counter), `eventsUpdatedTotal` (Counter), `eventsDeletedTotal` (Counter), `rsvpsWrittenTotal` (Counter, labelled by `status`), `invitationsSentTotal` (Counter). Instruments SHALL be lazily built and cached; `resetEventsInstruments()` SHALL clear the cache for test re-binding.
#### Scenario: instruments emit on real production paths
- **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `POST /` succeeds
- **THEN** the next `forceFlush` of the in-memory reader contains `events.created_total` with value `1`
**Verified by:** `src/otel/instruments.test.ts::"getEventsInstruments returns the same bundle on repeated calls"` AND `src/otel/instruments.test.ts::"emits events.created_total when a create succeeds"` AND `src/otel/instruments.test.ts::"resetEventsInstruments rebinds against a fresh provider"`
---
### Requirement: Service registry augmentation
The package SHALL augment `@suaveplan/api-plugin/service-registry` `ServiceRegistry` with `eventRepository: IEventRepository` and `rsvpRepository: IRsvpRepository` slots so downstream plugins resolve them via `ctx.getService("eventRepository")` with full typing.
#### Scenario: registered services resolve with the augmented type
- **WHEN** a downstream `PluginContext` calls `ctx.getService("eventRepository")` after the events plugin's `onInit` ran
- **THEN** the value is an `IEventRepository` (compile-time + runtime)
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"ServiceRegistry exposes eventRepository and rsvpRepository slots"`
---
### Requirement: Public surface (subpath exports)
The package SHALL expose the following subpath exports in `package.json`: `.`, `./plugin/plugin`, `./schemas/options`, `./schemas/event-schemas`, `./schemas/rsvp-schemas`, `./error-codes/error-codes`, `./otel/instruments`, `./repositories/event-repository/event-repository`, `./repositories/rsvp-repository/rsvp-repository`, `./routes/events-routes/events-routes`, `./types/types`. The root barrel SHALL re-export every public symbol via explicit named exports (rule 10 — no `export *` outside generated forwarders).
#### Scenario: every documented symbol is re-exported by the root
- **WHEN** the spec-conformance test imports the package root and reflects over expected symbols
- **THEN** every expected symbol resolves to a non-`undefined` value
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports every documented symbol"`
---
### Requirement: SurrealDB migration payload
The package SHALL ship `surql/tables/event.surql` defining the SCHEMAFULL tables `event` and `event_rsvp` plus the supporting indexes (`event_start_idx`, `event_rsvp_unique_idx`). The migration SHALL parse and execute cleanly against a real SurrealDB v3 instance with no cross-plugin function references — the upstream cross-plugin permission rules and helper functions (`fn::bitwise::*`, `fn::event::*`, `fn::group::*`, `fn::notification::*`) and the auxiliary `event_update` table are intentionally scoped out (see proposal `## Out of scope`) because they depend on plugins that do not yet exist on genesis. Permissions are enforced at the route / repository layer, not the database layer.
#### Scenario: surql loads cleanly
- **WHEN** the full-boot integration test executes the migration against a real SurrealDB v3 container
- **THEN** subsequent `CREATE event` and `CREATE event_rsvp` statements succeed and round-trip the inserted fields
**Verified by:** `tests/integration/full-boot.test.ts::"boots, creates event, records rsvp, increments count, cleans up"`
---
### Requirement: Integration boot
The package SHALL boot end-to-end against a real SurrealDB v3 instance: `createEventsPlugin` → `onInit` → `onReady` → CRUD a real event → RSVP → delete. The integration test SHALL skip cleanly when `isSurrealDBAvailable()` is `false`.
#### Scenario: full lifecycle against real Surreal
- **WHEN** a SurrealDB v3 testcontainer is running and the integration test invokes the full boot
- **THEN** an `event` is created, an `rsvp` is recorded, the attendee count rises by 1, the rsvp is cancelled, and the event is deleted — every step round-trips through `db.query` without throwing
**Verified by:** `tests/integration/full-boot.test.ts::"boots, creates event, records rsvp, increments count, cleans up"`
### Requirement: ICS export endpoint
The package SHALL expose a `GET /:id/ics` route mounted by `createEventsPlugin` under the configured `basePath`. The route SHALL return HTTP `200` with `Content-Type: text/calendar; charset=utf-8` and a body containing one RFC 5545 `VCALENDAR` block enclosing a single `VEVENT` for the requested event. The response SHALL carry `Content-Disposition: attachment; filename="event-<id>.ics"` where `<id>` is the URL-safe event id. Authentication SHALL be required: a request without `c.get("user")` SHALL return HTTP `401` with body `{ error: "Authentication required", code: "EVENTS_PLUGIN.UNAUTHENTICATED" }`. A request whose event id does not resolve in `eventRepository.findById` SHALL return HTTP `404` with code `EVENTS_PLUGIN.EVENT_NOT_FOUND`.
#### Scenario: unauthenticated request returns 401
- **WHEN** `GET /event:1/ics` runs without `user` set in the Hono context
- **THEN** the response status is `401` and the body contains `code: "EVENTS_PLUGIN.UNAUTHENTICATED"`
#### Scenario: 200 success returns VCALENDAR with single VEVENT
- **WHEN** `GET /event:1/ics` runs with `user` set and `eventRepository.findById` returns an event
- **THEN** the response status is `200`, `Content-Type` is `text/calendar; charset=utf-8`, `Content-Disposition` is `attachment; filename="event-event:1.ics"`, and the body matches `/^BEGIN:VCALENDAR\r\n[\s\S]+\r\nEND:VCALENDAR\r\n$/` containing exactly one `BEGIN:VEVENT` / `END:VEVENT` pair
#### Scenario: missing event returns 404
- **WHEN** `GET /missing/ics` runs and `findById` returns `null`
- **THEN** the response status is `404` and the body carries `code: "EVENTS_PLUGIN.EVENT_NOT_FOUND"`
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 401 without user"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 200 with single VEVENT VCALENDAR"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 404 when event missing"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics sets Content-Disposition attachment header"`
---
### Requirement: VEVENT body content
The single `VEVENT` block SHALL emit the following properties, sourced from the event fields shipped on `EventSchema`: `UID` as `${eventId}@suaveplan.events`; `DTSTAMP` from the injected `Clock` formatted as UTC `YYYYMMDDTHHMMSSZ` per §3.3.5; `DTSTART` from `event.start_time` formatted as UTC `YYYYMMDDTHHMMSSZ`; `DTEND` from `event.end_time` formatted the same way; `SUMMARY` from `event.title` with §3.3.11 escaping applied; `DESCRIPTION` from `event.description` if present, also escaped; `LOCATION` from `event.location.name` if present, also escaped; `STATUS` mapped from the event status integer (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`) per §3.8.1.11; and `SEQUENCE:0`. `ORGANIZER` SHALL NOT be emitted in v0.1 because the event schema does not carry a host email — the empty slot is documented in the proposal and remains reserved for a future schema field.
#### Scenario: every required property is present in order
- **WHEN** the builder runs against an event with title `"T"`, description `"d"`, location name `"L"`, start `2026-01-01T10:00:00Z`, end `2026-01-01T12:00:00Z`, status `1`
- **THEN** the body contains exactly one of each line in order: `BEGIN:VEVENT`, `UID:event:1@suaveplan.events`, `DTSTAMP:...Z`, `DTSTART:20260101T100000Z`, `DTEND:20260101T120000Z`, `SUMMARY:T`, `DESCRIPTION:d`, `LOCATION:L`, `STATUS:CONFIRMED`, `SEQUENCE:0`, `END:VEVENT`
#### Scenario: status maps draft → TENTATIVE and cancelled → CANCELLED
- **WHEN** the builder runs with `status: 0` and again with `status: 2`
- **THEN** the first body contains `STATUS:TENTATIVE` and the second contains `STATUS:CANCELLED`
#### Scenario: optional fields omit when absent
- **WHEN** the builder runs with `description` undefined and `location` undefined
- **THEN** the body contains no `DESCRIPTION:` or `LOCATION:` line
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"emits every required VEVENT property in canonical order"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps status integer to RFC 5545 STATUS value"` AND `src/ics/ics-builder/ics-builder.test.ts::"omits DESCRIPTION and LOCATION when absent"`
---
### Requirement: Text escape rules per §3.3.11
Every value written into `SUMMARY`, `DESCRIPTION`, or `LOCATION` SHALL be escaped per RFC 5545 §3.3.11: backslash → `\\`, semicolon → `\;`, comma → `\,`, newline (`\n` and `\r\n`) → `\n` (the literal two characters `\` + `n`). Carriage returns inside text SHALL be normalised away before escaping. Escaping SHALL happen before line folding.
#### Scenario: comma semicolon backslash newline are escaped
- **WHEN** `escapeText("a,b;c\\d\ne")` runs
- **THEN** the result is the exact string `a\,b\;c\\d\ne`
#### Scenario: CRLF in source becomes a single escaped \n
- **WHEN** `escapeText("a\r\nb")` runs
- **THEN** the result is the exact string `a\nb`
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"escapeText escapes comma semicolon backslash and newline"` AND `src/ics/ics-builder/ics-builder.test.ts::"escapeText normalises CRLF to a single \\n"`
---
### Requirement: Line folding per §3.1
Every emitted content line longer than 75 octets SHALL be folded: the line is split into 75-octet segments and each subsequent segment is prefixed with a single SPACE on a new CRLF-terminated line, exactly as specified in RFC 5545 §3.1. Folding SHALL operate on the post-escape byte stream so multi-byte UTF-8 sequences are not split mid-codepoint when avoidable (the implementation MAY fold at the nearest safe byte boundary ≤75 octets to preserve UTF-8). Every output line including the final `END:VCALENDAR` SHALL terminate with CRLF (`\r\n`).
#### Scenario: a 200-character DESCRIPTION folds into three CRLF-separated segments
- **WHEN** the builder runs against an event whose escaped `DESCRIPTION` is 200 ASCII octets long
- **THEN** the body contains `DESCRIPTION:` followed by the first 63 octets on the first physical line (75 = `DESCRIPTION:` 12 octets + 63 payload), then `\r\n` (CRLF + space) before each subsequent 74-octet payload segment, and every physical line ends in `\r\n`
#### Scenario: every output line ends in CRLF
- **WHEN** `buildVCalendar` runs against any valid input
- **THEN** the body contains zero `\n` bytes that are not preceded by `\r`
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"folds a 200-octet DESCRIPTION into 75-octet continuation segments"` AND `src/ics/ics-builder/ics-builder.test.ts::"every output line ends in CRLF"`
---
### Requirement: RSVP attendee export (?with=rsvps)
When the request includes `?with=rsvps`, the response body SHALL contain one `ATTENDEE` line inside the `VEVENT` per row returned by `rsvpRepository.findAttendees(eventId)`. Each `ATTENDEE` line SHALL carry the `CN` parameter from the attendee's `user_name` if present (escaped per §3.3.11), a `PARTSTAT` parameter mapped from the RSVP status (`going`→`ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`, `invited`→`NEEDS-ACTION`), and the CAL-ADDRESS value as `mailto:<user_email>` when `user_email` is present or `mailto:<user_id>@suaveplan.local` as a non-routable fallback when it is not. The `?with=rsvps` variant SHALL require the requester to be the event creator: a non-creator SHALL receive HTTP `403` with code `EVENTS_PLUGIN.FORBIDDEN`. The plain `GET /:id/ics` (no `with` query) SHALL NOT emit any `ATTENDEE` lines and SHALL NOT require creator privileges.
#### Scenario: with=rsvps emits one ATTENDEE per RSVP
- **WHEN** `GET /event:1/ics?with=rsvps` runs with `user` = creator and `findAttendees` returns 2 RSVP rows
- **THEN** the body contains exactly 2 `ATTENDEE` lines, each carrying a `PARTSTAT=` parameter and a `mailto:` value
#### Scenario: with=rsvps maps PARTSTAT values
- **WHEN** the builder runs with attendees of status `going`, `maybe`, `not_going`, `invited`
- **THEN** the corresponding ATTENDEE lines carry `PARTSTAT=ACCEPTED`, `PARTSTAT=TENTATIVE`, `PARTSTAT=DECLINED`, `PARTSTAT=NEEDS-ACTION`
#### Scenario: with=rsvps by non-creator returns 403
- **WHEN** `GET /event:1/ics?with=rsvps` runs with a user where `isCreator` returns `false`
- **THEN** the response status is `403` and the body carries `code: "EVENTS_PLUGIN.FORBIDDEN"`
#### Scenario: invalid with value returns 422
- **WHEN** `GET /event:1/ics?with=bogus` runs
- **THEN** the response status is `422` and the body carries `code: "EVENTS_PLUGIN.ICS_INVALID_QUERY"`
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps emits one ATTENDEE per RSVP"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps RSVP status to ATTENDEE PARTSTAT"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps returns 403 for non-creator"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 422 for invalid with value"`
---
### Requirement: New error code
The package SHALL extend `EVENTS_PLUGIN_CODES` with `ICS_INVALID_QUERY` (registry size 12 → 13). The code SHALL be raised when the Zod schema for the ICS route query rejects an unknown `with` value, surfaced to the client as HTTP `422`. The code SHALL be thrown by at least one production path under `src/routes/ics-export/`.
#### Scenario: ICS_INVALID_QUERY is reachable
- **WHEN** the error-codes conformance test reflects over `EVENTS_PLUGIN_CODES` and greps `src/` for a `new EventsPluginError(EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY, ...)` or `code: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` call site
- **THEN** at least one match is found
**Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"`
---
### Requirement: OTel counters
The package SHALL extend `EventsInstruments` with two new counters created against `metrics.getMeter("@suaveplan/api-plugin-events")`: `icsServedTotal` (instrument name `events.ics.served_total`, labelled with `with` taking values `"none"` or `"rsvps"`) and `icsFailedTotal` (instrument name `events.ics.failed_total`, labelled with `reason` taking values `"not_found"`, `"forbidden"`, `"unauthenticated"`, or `"invalid_query"`). Both counters SHALL be lazily built, cached, and cleared by `resetEventsInstruments()`. The ICS route SHALL increment `icsServedTotal` on every successful response and `icsFailedTotal` on every error response.
#### Scenario: success path increments served_total
- **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `GET /event:1/ics` returns 200
- **THEN** the next `forceFlush` of the in-memory reader contains `events.ics.served_total` with attribute `with=none` and value `1`
#### Scenario: 404 path increments failed_total
- **WHEN** the same setup runs and `GET /missing/ics` returns 404
- **THEN** the next `forceFlush` contains `events.ics.failed_total` with attribute `reason=not_found` and value `1`
**Verified by:** `src/otel/instruments.test.ts::"exposes ics served and failed counters"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.served_total on success"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.failed_total on 404"`
---
### Requirement: Public surface (subpath exports + named re-exports)
The package SHALL add two new subpath exports in `package.json`: `./ics/ics-builder/ics-builder` and `./routes/ics-export/ics-export`. The root barrel SHALL add explicit named re-exports of `createIcsExportRoutes`, `buildVCalendar`, `BuildVCalendarInput`, `IcsAttendee`, `IcsExportRoutesConfig`, and `IcsEventStatus` (rule 10 — no `export *` outside generated forwarders).
#### Scenario: new symbols resolve from the package root
- **WHEN** the conformance test imports the package root and asserts the new symbols resolve to non-`undefined`
- **THEN** every expected symbol matches
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports ICS surface (builder + routes)"`
---
### Requirement: Plugin wires ICS routes alongside CRUD routes
`createEventsPlugin`'s `onReady` SHALL mount `createIcsExportRoutes(...)` on the same `OpenAPIHono` instance under the same `basePath` as `createEventRoutes(...)`. Both route modules SHALL share the host-supplied `user` context variable. The plugin SHALL pass `Date.now` as the default `Clock` to the ICS routes; tests MAY override via `createIcsExportRoutes` directly.
#### Scenario: plugin onReady mounts an ICS route
- **WHEN** `onReady` runs against a fresh registry and app
- **THEN** the app's `routes` array contains at least one entry whose `path` ends in `/:id/ics`
**Verified by:** `src/plugin/plugin.test.ts::"onReady mounts the ICS export route"`

View File

@@ -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"`

View File

@@ -0,0 +1,456 @@
# Capability Spec Delta: api-plugin-export (add-api-plugin-export)
**Package**: `@suaveplan/api-plugin-export`
**Status**: SHIPPED
## ADDED Requirements
### Requirement: Plugin descriptor and lifecycle
`createExportPlugin(options?)` SHALL return an immutable `PluginDescriptor` named `"export"` at version `"0.1.0"` with `optionalDependencies: ["database", "events"]` and `tags: ["export","data","csv","json","xlsx"]`. Construction-time Zod validation of `options` SHALL surface `ExportError(EXPORT_CODES.INVALID_CONFIG)` carrying the `ZodError` as `cause` on rejection. The `onInit` hook SHALL register `exportProviders: Map<string, DataProvider>` and `exportJobs: Map<string, ExportJob>` on the `ServiceRegistry`. The `onReady` hook SHALL look up the host `app` via `ctx.getService("app")` and mount routes built by `createExportRoutes(...)` at the configured `basePath`. The `onClose` hook SHALL clear both maps.
#### Scenario: invalid options reject at construction time
- **WHEN** `createExportPlugin({ maxRows: 0 })` is called
- **THEN** `ExportError` is thrown with `code === EXPORT.INVALID_CONFIG` and the `ZodError` is attached as `cause`
#### Scenario: caller-supplied options flow through to onReady
- **WHEN** the registry calls `onReady(ctx)` after `createExportPlugin({ basePath: "/v1/exports", maxRows: 100 })`
- **THEN** the routes are mounted at `/v1/exports` and `runExport` against a 101-row provider rejects with `EXPORT.ROW_LIMIT_EXCEEDED`
#### Scenario: onClose drains plugin state
- **WHEN** `onClose()` is invoked after providers and jobs have been populated
- **THEN** both maps are empty
**Verified by:** `src/plugin/plugin.test.ts::"createExportPlugin rejects invalid options"` AND `src/plugin/plugin.test.ts::"createExportPlugin honours caller-supplied options through onReady"` AND `src/plugin/plugin.test.ts::"onClose drains providers and jobs"`
---
### Requirement: CSV exporter (RFC 4180)
`CsvExporter.export(data, columns, options?)` SHALL produce an `ExportResult` whose `content` is a CSV string conforming to RFC 4180. Field values containing the configured `delimiter`, `"`, `\n`, or `\r` SHALL be wrapped in double quotes with embedded `"` doubled. The header row SHALL be included unless `options.includeHeaders === false`. `Date` instances SHALL serialise via `Date.prototype.toISOString()`; `null`/`undefined` SHALL serialise to the empty string; plain objects SHALL serialise via `JSON.stringify`.
#### Scenario: quoting rules
- **WHEN** a row contains `'a"b'`, `'a,b'`, and `'a\nb'`
- **THEN** each field appears double-quoted with `"` doubled
#### Scenario: header opt-out
- **WHEN** `options.includeHeaders === false`
- **THEN** the output starts with the first data row
**Verified by:** `src/exporters/csv/csv.test.ts::"escapes quote / delimiter / newline per RFC 4180"` AND `src/exporters/csv/csv.test.ts::"omits the header row when includeHeaders is false"`
---
### Requirement: JSON exporter
`JsonExporter.export(data, columns, options?)` SHALL project each row to a `Record<string, unknown>` keyed by `column.header`, applying `column.transform` when present, and return a string from `JSON.stringify(projected, null, options?.pretty ? 2 : undefined)`. The MIME type SHALL be `application/json`.
#### Scenario: pretty-print
- **WHEN** `options.pretty === true`
- **THEN** the output contains a newline immediately after `[`
#### Scenario: transform applied
- **WHEN** a column declares `transform: (v) => Number(v) * 2`
- **THEN** the projected value is the doubled number
**Verified by:** `src/exporters/json/json.test.ts::"pretty-prints when options.pretty is true"` AND `src/exporters/json/json.test.ts::"applies column.transform"`
---
### Requirement: XLSX (SpreadsheetML) exporter
`XlsxExporter.export(data, columns, options?)` SHALL produce a SpreadsheetML XML document carrying one `worksheet/sheetData` element. Column letters SHALL follow the Excel scheme: index 0 → `A`, 25 → `Z`, 26 → `AA`. Numbers SHALL emit `<c><v>…</v></c>`, booleans `<c t="b"><v>0|1</v></c>`, everything else `<c t="inlineStr"><is><t>…</t></is></c>` with XML special characters escaped. The MIME type SHALL be `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`.
#### Scenario: column-letter overflow
- **WHEN** the 27th column is emitted
- **THEN** its cell reference uses the `AA` letter
#### Scenario: numeric vs. inline-string cells
- **WHEN** a row contains a number `42` and a string `"forty-two"`
- **THEN** the numeric cell has no `t="…"` attribute and the string cell has `t="inlineStr"`
**Verified by:** `src/exporters/xlsx/xlsx.test.ts::"emits AA for column index 26"` AND `src/exporters/xlsx/xlsx.test.ts::"distinguishes numeric from inline-string cells"`
---
### Requirement: Export service helpers
`getExporter(format)` SHALL return a fresh instance of the matching exporter (`csv` / `json` / `xlsx`) and SHALL throw `ExportError(EXPORT_CODES.UNSUPPORTED_FORMAT)` on any other value. `inferColumns(data)` SHALL return one `ColumnDefinition` per distinct key across all rows, in first-seen order, with `key === header`. `runExport(provider, format, columns?, filters?, options?, config?)` SHALL fetch data through `provider.fetch(filters)`, reject with `ExportError(EXPORT_CODES.ROW_LIMIT_EXCEEDED)` when `data.length > config.maxRows`, run the exporter, then reject with `ExportError(EXPORT_CODES.FILE_SIZE_EXCEEDED)` when `result.byteSize > config.maxFileSizeBytes`.
#### Scenario: row limit guard
- **WHEN** `provider.fetch` resolves with 51 rows and `config.maxRows === 50`
- **THEN** `runExport` rejects with `EXPORT.ROW_LIMIT_EXCEEDED`
#### Scenario: file-size guard
- **WHEN** the exporter result `byteSize` exceeds `config.maxFileSizeBytes`
- **THEN** `runExport` rejects with `EXPORT.FILE_SIZE_EXCEEDED`
#### Scenario: unsupported format
- **WHEN** `getExporter("parquet" as ExportFormat)` is called
- **THEN** it throws `EXPORT.UNSUPPORTED_FORMAT`
**Verified by:** `src/services/export/export-service.test.ts::"runExport rejects when row count exceeds the configured maximum"` AND `src/services/export/export-service.test.ts::"runExport rejects when result byteSize exceeds the configured maximum"` AND `src/services/export/export-service.test.ts::"getExporter throws on unsupported format"`
---
### Requirement: Export routes
`createExportRoutes(deps)` SHALL return an `OpenAPIHono` exposing four routes:
- `GET /` SHALL apply `status` / `format` / `resource` filters from `ExportJobListQuerySchema`, then `offset`/`limit` window the in-memory `jobs` map.
- `POST /` SHALL look up `deps.providers.get(body.resource)`, return `404 { error }` on miss, record a fresh `ExportJob`, fire `deps.onExportStarted`, run the export through `provider.fetch` → `inferColumns` → `getExporter` → `exporter.export`, enforce row + size caps (`400 { error }` on breach), update the job to `completed`, fire `deps.onExportCompleted`, and respond `200 { job, downloadUrl }`.
- `GET /:id` SHALL return the job or `404 { error: "Export job not found" }`.
- `DELETE /:id` SHALL remove the job (`200 { success: true }`) or `404 { error }`.
All input parsing SHALL flow through `@hono/zod-openapi` validators on `ExportJobListQuerySchema`, `CreateExportJobSchema`, and `z.object({ id: z.string() })`.
#### Scenario: POST with missing provider
- **WHEN** `POST /` body references a `resource` not in `deps.providers`
- **THEN** the response is `404` with `{ error: "No data provider registered for resource: <name>" }`
#### Scenario: POST honours row + size caps
- **WHEN** the provider returns 51 rows and `exportConfig.maxRows === 50`
- **THEN** the response is `400` and the job ends in `status: "failed"` with a row-limit error message
#### Scenario: GET list filter + pagination
- **WHEN** three jobs exist (two `completed`, one `failed`) and the query is `?status=completed&limit=1`
- **THEN** the response carries `total: 2` and `items.length === 1`
#### Scenario: DELETE missing job
- **WHEN** `DELETE /missing-id` is called and the id is absent
- **THEN** the response is `404 { error: "Export job not found" }`
**Verified by:** `src/routes/exports/exports.test.ts::"POST /exports returns 404 when no provider is registered"` AND `src/routes/exports/exports.test.ts::"POST /exports rejects when row count exceeds the configured maximum"` AND `src/routes/exports/exports.test.ts::"GET /exports applies status filter and pagination"` AND `src/routes/exports/exports.test.ts::"DELETE /exports/{id} returns 404 when the job is missing"`
---
### Requirement: Error-code registry
`EXPORT_CODES` SHALL be built via `defineCodes("EXPORT", […])` and include exactly these codes: `INVALID_CONFIG`, `PROVIDER_NOT_FOUND`, `ROW_LIMIT_EXCEEDED`, `FILE_SIZE_EXCEEDED`, `UNSUPPORTED_FORMAT`, `PROVIDER_FETCH_FAILED`, `JOB_NOT_FOUND`. `ExportError` SHALL extend `SuaveplanError<ExportErrorCode>` so `error.code` narrows to the union and `instanceof ExportError` narrows by class. Every code in the registry SHALL be thrown by at least one production code path (pinned by `api-plugin-export.spec-conformance.test.ts`).
#### Scenario: code narrowing
- **WHEN** `throw new ExportError(EXPORT_CODES.ROW_LIMIT_EXCEEDED, "…")` is caught
- **THEN** `error.code === "EXPORT.ROW_LIMIT_EXCEEDED"` and `error.exportCode` narrows to the union
#### Scenario: spec-conformance pin
- **WHEN** the conformance test enumerates `EXPORT_CODES`
- **THEN** every value is referenced from at least one non-test source file
**Verified by:** `src/error-codes/error-codes.test.ts::"ExportError narrows code via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry instruments
`getExportInstruments()` SHALL lazily resolve a `Meter` named `@suaveplan/api-plugin-export` from the global `MeterProvider` and return a cached bundle of four instruments: `jobsTotal: Counter` (with `outcome: "completed" | "failed"`), `rowsTotal: Counter`, `bytesTotal: Counter`, `durationMs: Histogram`. `resetExportInstruments()` SHALL drop the cache so the next call rebinds against the current global provider. The production POST route SHALL emit `jobsTotal` on every job outcome and `rowsTotal` / `bytesTotal` / `durationMs` on every `completed` outcome.
#### Scenario: completed-job metrics emitted
- **WHEN** a successful export completes
- **THEN** an in-memory `MeterProvider`'s exporter sees one increment of `export.jobs_total{outcome=completed}` plus the row/byte/duration metrics
#### Scenario: failed-job metric emitted on row-limit breach
- **WHEN** an export exceeds `maxRows`
- **THEN** the exporter sees one increment of `export.jobs_total{outcome=failed}`
**Verified by:** `src/otel/instruments.test.ts::"emits completed-job metrics through an in-memory MeterProvider"` AND `src/otel/instruments.test.ts::"emits failed-job metric on row-limit breach"`
---
### Requirement: Public surface and side-effect imports
The package root barrel SHALL import `./error-codes/error-codes.js` and `./services/registry-augmentation.js` for their side effects, and SHALL re-export every public symbol by name (no `export *` in any feature-module barrel). The barrel SHALL additionally re-export `ExportJobStore` (type), `MemoryExportJobStore`, `SurrealExportStore`, `SurrealExportStoreConfig` (type), and the `JOB_PERSISTENCE_FAILED` member of `EXPORT_CODES`. `package.json` SHALL declare `sideEffects: false` and ship subpath exports for `./plugin/plugin`, `./services/export/export-service`, `./routes/exports/exports`, `./exporters/csv/csv`, `./exporters/json/json`, `./exporters/xlsx/xlsx`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`, `./store/types/types`, `./store/memory/memory-store`, `./store/surreal/surreal-store`. The `files` array SHALL include `"surql"` so the migration ships to npm.
#### Scenario: side-effect imports present
- **WHEN** `src/index.ts` is read
- **THEN** the file contains both `./error-codes/error-codes.js` and `./services/registry-augmentation.js` import statements
#### Scenario: no `export *` in feature barrels
- **WHEN** `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" src/` is run against the package
- **THEN** no matches are found
#### Scenario: store subpath exports resolve
- **WHEN** a consumer imports from `@suaveplan/api-plugin-export/store/surreal/surreal-store`
- **THEN** the import resolves to a module exporting `SurrealExportStore`
**Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"side-effect imports of error-codes and registry-augmentation are present in index.ts"` AND `src/api-plugin-export.spec-conformance.test.ts::"store subpath exports resolve from package.json"`
---
### Requirement: Export job store contract (`ExportJobStore`)
The package SHALL expose an `ExportJobStore` interface defining the persistence contract for export jobs. The interface SHALL declare async methods `get(id)`, `set(id, job)`, `delete(id)`, `list(query?)`, `has(id)`, and `clear()`. `list(query?)` SHALL accept an `ExportJobStoreListQuery` carrying optional `status`, `format`, `resource` filters and required `limit`, `offset` pagination, returning `{ items: ExportJob[]; total: number }`. The interface SHALL be runtime-free (a type-only export) so consumers can implement it without pulling in any backend dependency.
#### Scenario: interface members
- **WHEN** `ExportJobStore` is consumed as a TypeScript type
- **THEN** it declares the six async methods above with the documented signatures
**Verified by:** `src/store/memory/memory-store.test.ts::"MemoryExportJobStore satisfies the ExportJobStore contract"`
---
### Requirement: In-memory backend (`MemoryExportJobStore`)
`MemoryExportJobStore` SHALL implement `ExportJobStore` over an internal `Map<string, ExportJob>`. Every method SHALL resolve synchronously-derived results through a `Promise`. `list({ status, format, resource, limit, offset })` SHALL apply each provided filter as an AND-conjunction over the in-memory map values, then window with `offset`/`limit`, returning `total` as the pre-window match count.
#### Scenario: round-trip a job
- **WHEN** `set("a", job)` is called and then `get("a")`
- **THEN** the resolved job deep-equals the input
#### Scenario: list filter conjunction
- **WHEN** three jobs differ on `status` / `format` and the query is `{ status: "completed", format: "csv", limit: 10, offset: 0 }`
- **THEN** only the matching job appears in `items` and `total === 1`
#### Scenario: pagination
- **WHEN** five jobs are stored and the query is `{ limit: 2, offset: 2 }`
- **THEN** `items.length === 2` and `total === 5`
#### Scenario: clear drains the store
- **WHEN** `clear()` is called after jobs are stored
- **THEN** `list()` resolves with `{ items: [], total: 0 }`
**Verified by:** `src/store/memory/memory-store.test.ts::"set + get round-trips a job"` AND `src/store/memory/memory-store.test.ts::"list applies AND-conjunction filters"` AND `src/store/memory/memory-store.test.ts::"list paginates with offset and limit"` AND `src/store/memory/memory-store.test.ts::"clear empties the store"`
---
### Requirement: SurrealDB backend (`SurrealExportStore`)
`SurrealExportStore` SHALL implement `ExportJobStore` over a SurrealDB `export_job` table reached through `@suaveplan/surrealdb` `SurrealAdapter`. All SurQL SHALL use positional `$p0…$pN` placeholders (genesis-canonical pattern; the adapter rewrites positional arrays into named bindings). The constructor SHALL accept a Zod-validated `SurrealExportStoreConfig` carrying `db: SurrealAdapter` and an optional `tableName: string` defaulting to `"export_job"`. Every method SHALL wrap underlying driver / network / parse failures in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` with the original error chained as `cause`. `set(id, job)` SHALL upsert the row via `UPSERT type::record($p0, $p1) CONTENT $p2 RETURN AFTER`. `delete(id)` SHALL be idempotent — deleting a missing row SHALL resolve without throwing. `list({ status, format, resource, limit, offset })` SHALL emit a `SELECT * FROM <tableName>` with an AND-conjunction `WHERE` clause and a parallel `SELECT count() AS count FROM <tableName> GROUP ALL` (also filtered) to derive `total` in a single round-trip latency via `Promise.all`.
#### Scenario: round-trip a job through SurrealDB
- **WHEN** `set("abc", job)` resolves and then `get("abc")` resolves
- **THEN** the resolved job carries the same `format`, `resource`, `status`, `createdAt` as the input
#### Scenario: list with filters
- **WHEN** three rows are seeded (two `completed`, one `failed`) and `list({ status: "completed", limit: 10, offset: 0 })` is called
- **THEN** `items.length === 2` and `total === 2`
#### Scenario: delete idempotency
- **WHEN** `delete("missing-id")` is called against an empty table
- **THEN** the call resolves with no error
#### Scenario: driver failure surfaces JOB_PERSISTENCE_FAILED
- **WHEN** the underlying adapter `query(...)` rejects (e.g. connection drop, schema mismatch)
- **THEN** the call rejects with `ExportError` whose `code === "EXPORT.JOB_PERSISTENCE_FAILED"` and whose `cause` is the underlying error
#### Scenario: invalid config rejects at construction time
- **WHEN** `new SurrealExportStore({})` is called without a `db` adapter
- **THEN** `ExportError` is thrown with `code === "EXPORT.INVALID_CONFIG"` and the `ZodError` is attached as `cause`
**Verified by:** `tests/integration/surreal-store.test.ts::"set + get round-trips a job through SurrealDB"` AND `tests/integration/surreal-store.test.ts::"list applies status filter and pagination through SurrealDB"` AND `tests/integration/surreal-store.test.ts::"delete on a missing row is idempotent"` AND `src/store/surreal/surreal-store.test.ts::"driver failure surfaces JOB_PERSISTENCE_FAILED"` AND `src/store/surreal/surreal-store.test.ts::"invalid config rejects with INVALID_CONFIG"`
---
### Requirement: `JOB_PERSISTENCE_FAILED` error code
`EXPORT_CODES` SHALL include the additional code `JOB_PERSISTENCE_FAILED`. The code SHALL be thrown from at least one production path — the `SurrealExportStore` driver-failure branch — and SHALL be pinned by `api-plugin-export.spec-conformance.test.ts`.
#### Scenario: registry membership
- **WHEN** `Object.values(EXPORT_CODES)` is enumerated
- **THEN** the result contains `"EXPORT.JOB_PERSISTENCE_FAILED"`
#### Scenario: conformance pin
- **WHEN** the spec-conformance suite walks the registry against `src/**/*.ts` non-test files
- **THEN** `JOB_PERSISTENCE_FAILED` is present in `src/store/surreal/surreal-store.ts`
**Verified by:** `src/error-codes/error-codes.test.ts::"JOB_PERSISTENCE_FAILED narrows via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"`
---
### Requirement: SurrealDB migration (`export_job.surql`)
The package SHALL ship `surql/tables/export_job.surql` defining the `export_job` table as `SCHEMAFULL` with one `DEFINE FIELD OVERWRITE` per `ExportJobSchema` property (`format`, `resource`, `status`, `filters`, `filename`, `totalRows`, `exportedRows`, `fileSize`, `error`, `createdAt`, `completedAt`) and indexes on `(status)`, `(format)`, `(resource)`, `(createdAt)`. The migration SHALL be idempotent (`DEFINE TABLE OVERWRITE`, `DEFINE FIELD OVERWRITE`, `DEFINE INDEX OVERWRITE`) so consecutive boots succeed.
#### Scenario: migration applies cleanly
- **WHEN** the migration is loaded twice against a fresh SurrealDB testcontainer
- **THEN** both applies succeed without error
**Verified by:** `tests/integration/surreal-store.test.ts::"export_job migration is idempotent"`
---
### Requirement: `GET /:id/download` endpoint
`createExportRoutes(deps)` SHALL register an additional route `GET /{id}/download` that serves
the rendered file body for a previously completed export job. The handler SHALL look up the
job via `deps.jobStore?.get(id)` when an `ExportJobStore` is wired and otherwise via
`deps.jobs.get(id)` against the in-memory map. When the job is missing the handler SHALL
respond `404 application/problem+json` with `code === "EXPORT.JOB_NOT_FOUND"`. When the job
is present but `status !== "completed"` the handler SHALL respond `409 application/problem+json`
with `code === "EXPORT.JOB_NOT_READY"`. When the job is present but the originally registered
`DataProvider` has been removed from `deps.providers`, the handler SHALL respond
`404 application/problem+json` with `code === "EXPORT.PROVIDER_NOT_FOUND"`. On any throw from
`provider.fetch(...)` or the exporter the handler SHALL respond `502 application/problem+json`
with `code === "EXPORT.PROVIDER_FETCH_FAILED"`. On success the handler SHALL respond `200`
with `Content-Type` set to the writer's `mimeType` (`text/csv` / `application/json` /
`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) and
`Content-Disposition: attachment; filename="<job.filename>"`.
#### Scenario: happy path streams CSV body
- **WHEN** a `completed` job for the `users` resource exists, the provider returns one row,
and a `GET /{id}/download` request is issued
- **THEN** the response is `200`, the `Content-Type` is `text/csv`, the
`Content-Disposition` carries `attachment; filename="…"`, and the body parses as RFC 4180 CSV
#### Scenario: missing job returns 404 problem-details
- **WHEN** `GET /missing/download` is issued and no job is stored under `missing`
- **THEN** the response is `404`, the `Content-Type` is `application/problem+json`, and the
body carries `{ code: "EXPORT.JOB_NOT_FOUND" }`
#### Scenario: not-ready job returns 409 problem-details
- **WHEN** `GET /pending-job/download` is issued and the stored job is in `status: "processing"`
- **THEN** the response is `409`, the `Content-Type` is `application/problem+json`, and the
body carries `{ code: "EXPORT.JOB_NOT_READY" }`
#### Scenario: missing provider returns 404 problem-details
- **WHEN** `GET /{id}/download` is issued for a job whose `resource` has no entry in
`deps.providers`
- **THEN** the response is `404` with `{ code: "EXPORT.PROVIDER_NOT_FOUND" }`
#### Scenario: provider failure returns 502 problem-details
- **WHEN** `provider.fetch` rejects during a `GET /{id}/download` request
- **THEN** the response is `502` with `{ code: "EXPORT.PROVIDER_FETCH_FAILED" }`
**Verified by:** `src/routes/download/download.test.ts::"streams a completed CSV job as application/csv with attachment disposition"` AND `src/routes/download/download.test.ts::"returns 404 problem-details when the job is missing"` AND `src/routes/download/download.test.ts::"returns 409 problem-details when the job is not yet completed"` AND `src/routes/download/download.test.ts::"returns 404 problem-details when the provider was deregistered"` AND `src/routes/download/download.test.ts::"returns 502 problem-details when provider.fetch rejects"` AND `src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"`
---
### Requirement: `JOB_NOT_READY` error code
`EXPORT_CODES` SHALL include the additional code `JOB_NOT_READY`. The code SHALL be thrown
from at least one production path — the `runDownloadExport` not-ready branch — and SHALL be
pinned by `api-plugin-export.spec-conformance.test.ts`. `ExportError` SHALL narrow
`error.exportCode` through the public type union.
#### Scenario: registry membership
- **WHEN** `Object.values(EXPORT_CODES)` is enumerated
- **THEN** the result contains `"EXPORT.JOB_NOT_READY"`
#### Scenario: code narrowing
- **WHEN** `throw new ExportError(EXPORT_CODES.JOB_NOT_READY, "…")` is caught
- **THEN** `error.code === "EXPORT.JOB_NOT_READY"` and `error.exportCode` narrows to the union
**Verified by:** `src/error-codes/error-codes.test.ts::"JOB_NOT_READY narrows via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"`
---
### Requirement: Per-package RFC 7807 problem-details renderer
The package SHALL ship a per-package RFC 7807 renderer at `src/routes/problem-json/problem-json.ts`
exporting `PROBLEM_JSON_CONTENT_TYPE = "application/problem+json"`, a `ProblemDetails` shape,
`renderProblem(err)` (pure mapping), and `respondProblemJson(c, err)` (writes the
`Content-Type` header and JSON body to a Hono context). `SuaveplanError` instances SHALL
surface their `code` and read `context.statusCode` for the HTTP status; non-numeric or
sub-400 values SHALL coerce to `500`. Plain `Error` instances SHALL render as
`{ type: "urn:suaveplan:error:internal", status: 500 }`. Unknown throwables SHALL render as
`{ type: "urn:suaveplan:error:internal", title: "InternalError", status: 500 }`.
#### Scenario: SuaveplanError maps to typed problem-details
- **WHEN** `renderProblem(new ExportError(EXPORT_CODES.JOB_NOT_READY, "...", { context: { statusCode: 409 } }))` runs
- **THEN** the returned object has `status: 409`, `code: "EXPORT.JOB_NOT_READY"`, and
`type: "urn:suaveplan:error:export.job_not_ready"`
#### Scenario: plain Error falls through to internal 500
- **WHEN** `renderProblem(new Error("boom"))` runs
- **THEN** the returned object has `status: 500`, `title: "Error"`, and no `code` field
#### Scenario: unknown throwable falls through to internal 500
- **WHEN** `renderProblem("raw-string")` runs
- **THEN** the returned object has `status: 500`, `title: "InternalError"`, and
`detail: "Internal Server Error"`
**Verified by:** `src/routes/problem-json/problem-json.test.ts::"maps SuaveplanError to typed problem-details with status from context"` AND `src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for plain Error"` AND `src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for unknown throwables"`
---
### Requirement: Download OTel instruments
`getExportInstruments()` SHALL additionally expose `downloadServed: Counter` (registered as
`export.download.served`, labelled `{ format }`) and `downloadFailed: Counter` (registered as
`export.download.failed`, labelled `{ reason }`). The download handler SHALL emit exactly one
counter increment per request — `downloadServed{format}` on the `ok` branch and
`downloadFailed{reason}` on every failure branch with `reason ∈ { "not-found", "not-ready",
"provider-not-found", "failed" }`.
#### Scenario: served counter increments on happy path
- **WHEN** a `200` download response is produced for a `csv` job
- **THEN** an in-memory `MeterProvider`'s exporter sees one increment of
`export.download.served{format=csv}`
#### Scenario: failed counter increments on not-ready
- **WHEN** a `409` download response is produced because the job is `processing`
- **THEN** the exporter sees one increment of `export.download.failed{reason=not-ready}`
**Verified by:** `src/otel/instruments.test.ts::"emits export.download.served on a successful CSV download"` AND `src/otel/instruments.test.ts::"emits export.download.failed{reason=not-ready} when the job is not completed"`
---
### Requirement: Plugin wires `exportJobStore` slot into routes
`createExportPlugin(...)` `onReady` SHALL look up `ctx.getService("exportJobStore")`. When
the slot resolves to an `ExportJobStore`, the plugin SHALL pass it through to
`createExportRoutes({ …, jobStore })` so the download handler reads job state from the
persistent backend rather than the in-memory `Map`. When the slot is undefined, the route
deps SHALL omit `jobStore` and the download handler SHALL fall back to the inline `deps.jobs`
adapter — preserving the existing in-memory route surface for host apps that opt out of
persistence.
#### Scenario: wired SurrealExportStore is honoured
- **WHEN** an app registers `MemoryExportJobStore` (interface-compatible with `SurrealExportStore`)
under `ctx.registerService("exportJobStore", store)` and the plugin's `onReady` runs
- **THEN** the download handler resolves the job through `store.get(id)`
#### Scenario: absent slot keeps in-memory fallback
- **WHEN** no `exportJobStore` slot is registered
- **THEN** the download handler still resolves jobs through `deps.jobs.get(id)`
**Verified by:** `src/plugin/plugin.test.ts::"download route uses exportJobStore service when wired"` AND `src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"`

View File

@@ -0,0 +1,210 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-file-upload
**Package**: `@suaveplan/api-plugin-file-upload`
**Status**: SHIPPED
## Requirements
### Requirement: createFileUploadPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createFileUploadPlugin(options): PluginDescriptor<FileUploadPluginConfig>` validating `options` against `FileUploadPluginOptionsSchema` at the factory boundary. Invalid input SHALL raise `FileUploadError(UPLOAD.INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "file-upload"`, the resolved `defaults`, and `onInit`/`onClose` lifecycle hooks.
#### Scenario: factory produces a valid descriptor
- **WHEN** `createFileUploadPlugin({})` is called
- **THEN** the returned descriptor satisfies `PluginDescriptor<FileUploadPluginConfig>` with `name === "file-upload"`
#### Scenario: invalid options raise UPLOAD.INVALID_INPUT
- **WHEN** `createFileUploadPlugin({ basePath: 123 } as never)` is called
- **THEN** `FileUploadError(UPLOAD.INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createFileUploadPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise UPLOAD_INVALID_INPUT"`
---
### Requirement: onInit registers 6 OpenAPI routes mounted under basePath
The descriptor's `onInit(ctx)` hook SHALL resolve `app` and `openAPIRegistry` from the `ServiceRegistry`, build 6 typed routes via `buildUploadRoutes`, and register them through `definePluginRoutes` under `config.basePath` (default `/uploads`). When `openAPIRegistry` is absent, `onInit` SHALL fall back to mounting Hono routes directly and log a warning. Missing `app` SHALL throw `FileUploadError(UPLOAD.SERVICE_NOT_AVAILABLE)`.
#### Scenario: routes registered through openAPIRegistry
- **WHEN** `ctx` provides both `app` and `openAPIRegistry` and the plugin initializes
- **THEN** `registry.getRoutesByPlugin("file-upload")` returns 6 entries
#### Scenario: missing app raises UPLOAD.SERVICE_NOT_AVAILABLE
- **WHEN** `onInit` runs with no `app` in `ServiceRegistry`
- **THEN** `FileUploadError(UPLOAD.SERVICE_NOT_AVAILABLE)` is thrown listing `app` as missing
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers 6 routes via definePluginRoutes"` AND `src/plugin/plugin.test.ts::"missing app raises UPLOAD_SERVICE_NOT_AVAILABLE"`
---
### Requirement: All 6 routes are requireAuth-gated and scoped to the request principal
Every route mounted by the plugin SHALL be wrapped by `requireAuth()`. Each route handler SHALL read `c.get("user")!.id` as the owning principal and `c.get("tenant")?.id` (optional) as tenant scope. Foreign `uploadId`s — uploads whose recorded principal does not match the current request's principal — SHALL respond `404 Not Found` (anti-enumeration). The 404 SHALL be `FileUploadError(UPLOAD.NOT_FOUND)`, never `UPLOAD.FORBIDDEN`.
#### Scenario: anonymous request rejected with 401
- **WHEN** any of the 6 routes is hit without a valid auth principal
- **THEN** the global `requireAuth()` middleware throws `AuthError(AUTH.UNAUTHENTICATED)` and the response is HTTP 401
#### Scenario: foreign uploadId returns 404 (anti-enumeration)
- **WHEN** `GET /uploads/{uploadId}` targets an upload created by another principal
- **THEN** the handler raises `FileUploadError(UPLOAD.NOT_FOUND)` and the response is HTTP 404
**Verified by:** `tests/integration/full-flow.test.ts::"anonymous request is rejected with 401"` AND `tests/integration/full-flow.test.ts::"foreign uploadId returns 404 (anti-enumeration)"`
---
### Requirement: Single-shot POST /uploads validates size, MIME, and stores the file
`POST /uploads` SHALL accept `multipart/form-data` with a single `file` part. It SHALL reject the upload when:
- the declared part size exceeds `config.singleShotLimitBytes` (default 10 MiB) → `FileUploadError(UPLOAD.TOO_LARGE)` → HTTP 413,
- the declared MIME type is not in `config.allowedMimeTypes` (when set) or is in `config.deniedMimeTypes` (defaults include common executable types) → `FileUploadError(UPLOAD.MIME_REJECTED)` → HTTP 415,
- the sniffed magic-bytes content type via `@suaveplan/mime` disagrees with the declared `Content-Type` → `FileUploadError(UPLOAD.MIME_REJECTED)` with `context.reason === "sniff-mismatch"`.
On success the route SHALL persist the bytes via `UploadStorage.put`, compute SHA-256, record an `UploadRecord`, and respond `200` with `{ uploadId, size, sha256, mimeType }`.
#### Scenario: single-shot upload succeeds
- **WHEN** an authenticated request POSTs a 1 KiB `text/plain` file
- **THEN** the response is HTTP 200 with a typed body matching `SingleShotResponseSchema`
#### Scenario: oversize single-shot upload rejected with 413
- **WHEN** the part size exceeds `singleShotLimitBytes`
- **THEN** the handler raises `FileUploadError(UPLOAD.TOO_LARGE)` → HTTP 413
#### Scenario: spoofed MIME rejected
- **WHEN** a PNG byte-stream is uploaded with declared `Content-Type: text/plain`
- **THEN** the handler raises `FileUploadError(UPLOAD.MIME_REJECTED)` with `context.reason === "sniff-mismatch"` → HTTP 415
**Verified by:** `tests/integration/full-flow.test.ts::"single-shot upload round-trip"` AND `tests/integration/full-flow.test.ts::"oversize single-shot upload rejected with 413"` AND `tests/integration/full-flow.test.ts::"MIME spoof rejected"`
---
### Requirement: Chunked upload — initiate, append, complete, abort, status
The chunked protocol SHALL expose:
- `POST /uploads/initiate` → creates an `UploadSession` and returns `{ uploadId, chunkSize, totalChunks }`. Body validated by `InitiateUploadBodySchema` carrying `fileName`, `totalSize`, `totalSha256`, and optional `mimeType` + `chunkSize`. Reject `totalSize > config.maxFileSizeBytes` with `UPLOAD.TOO_LARGE`.
- `PUT /uploads/{uploadId}/chunks/{chunkIndex}` → accepts raw bytes for the indexed chunk. SHALL raise `UPLOAD.CHUNK_OUT_OF_ORDER` (HTTP 409) when `chunkIndex >= totalChunks` or `chunkIndex < 0`. Re-uploading an already-received chunk SHALL be idempotent (200, no-op).
- `POST /uploads/{uploadId}/complete` → asserts every chunk index in `[0, totalChunks)` is present, concatenates, computes SHA-256, and verifies against the client-declared `totalSha256`. Mismatch SHALL raise `UPLOAD.HASH_MISMATCH` (HTTP 422). Missing chunks raise `UPLOAD.INCOMPLETE` (HTTP 409). On success: deletes the session, writes the assembled bytes via `UploadStorage.put`, returns the same `SingleShotResponseSchema` shape.
- `DELETE /uploads/{uploadId}` → idempotent abort: removes the session and any partial bytes; responds 204 whether or not the session existed (foreign principal → 404 first).
- `GET /uploads/{uploadId}` → returns `{ uploadId, totalChunks, receivedChunks: number[], state: "in-progress" | "complete" }` so clients can resume.
Sessions whose `createdAt + config.sessionTtlMs` is in the past SHALL be eligible for cleanup; `appendChunk`/`complete` against an expired session SHALL raise `UPLOAD.SESSION_EXPIRED` (HTTP 410).
#### Scenario: chunked round-trip succeeds
- **WHEN** an authenticated client initiates, uploads N chunks, then completes a 80 MiB payload in 5 MiB chunks
- **THEN** the assembled bytes match the original and the response is HTTP 200
#### Scenario: resume after crash mid-upload
- **WHEN** a client uploads chunks 0..K, restarts, then calls `GET /uploads/{uploadId}`
- **THEN** the response includes `receivedChunks` containing exactly `[0..K]` and the client may resume from `K+1`
#### Scenario: hash mismatch on complete
- **WHEN** the client-declared `totalSha256` does not match the assembled SHA-256
- **THEN** `complete` raises `UPLOAD.HASH_MISMATCH` (HTTP 422)
#### Scenario: chunk index out of range
- **WHEN** `PUT /uploads/{uploadId}/chunks/999` is hit on a session with `totalChunks=3`
- **THEN** `UPLOAD.CHUNK_OUT_OF_ORDER` (HTTP 409) is raised
#### Scenario: abort is idempotent
- **WHEN** `DELETE /uploads/{uploadId}` is called twice
- **THEN** both calls return 204 (the second is a no-op)
**Verified by:** `tests/integration/full-flow.test.ts::"chunked upload of 80MB in 5MB chunks"` AND `tests/integration/full-flow.test.ts::"resume after simulated crash mid-upload"` AND `src/assembler/assembler.test.ts::"hash mismatch raises UPLOAD_HASH_MISMATCH"` AND `src/services/file-upload-service.test.ts::"chunk index out of range raises UPLOAD_CHUNK_OUT_OF_ORDER"` AND `src/services/file-upload-service.test.ts::"abort is idempotent"`
---
### Requirement: fileUploadService registered in ServiceRegistry with typed slot
`onInit` SHALL call `ctx.registerService("fileUploadService", service)` exposing 6 methods: `initiate`, `appendChunk`, `complete`, `abort`, `get`, `delete`. The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` so downstream plugins get `ctx.getService("fileUploadService")` typed as `FileUploadService | undefined`.
#### Scenario: downstream plugin reads typed slot
- **WHEN** a sibling plugin calls `ctx.getService("fileUploadService")`
- **THEN** the return type is `FileUploadService | undefined` with no cast
**Verified by:** `src/services/file-upload-service.spec-conformance.test.ts::"ServiceRegistry slot typed as FileUploadService"` AND `src/services/file-upload-service.test.ts::"service exposes 6 methods"`
---
### Requirement: Pluggable UploadStorage adapter
The package SHALL export an `UploadStorage` interface with `put(key, bytes)`, `get(key)`, `delete(key)`, and `exists(key)` methods. Two concrete implementations SHALL ship: `MemoryUploadStorage` (test-only — documented as such) and `FileSystemUploadStorage` (production — backed by `node:fs/promises` writing under a `rootDir` with sanitized keys). `FileSystemUploadStorage` SHALL refuse paths that escape `rootDir` (path-traversal) by raising `FileUploadError(UPLOAD.STORAGE_FAILURE)`.
#### Scenario: FileSystemUploadStorage rejects path traversal
- **WHEN** `storage.put("../etc/passwd", bytes)` is called
- **THEN** `FileUploadError(UPLOAD.STORAGE_FAILURE)` is thrown
#### Scenario: MemoryUploadStorage round-trip
- **WHEN** `storage.put("k", bytes)` then `storage.get("k")` is called
- **THEN** the returned bytes match exactly
**Verified by:** `src/storage/file-system.test.ts::"rejects path traversal"` AND `src/storage/file-system.test.ts::"put/get/delete round-trip"` AND `src/storage/memory.test.ts::"put/get/delete round-trip"`
---
### Requirement: Pluggable UploadSessionStore
The package SHALL export an `UploadSessionStore` interface with `create`, `read`, `update`, `delete`, `appendChunkIndex`, `listExpired`. `MemoryUploadSessionStore` SHALL ship as the default. Stores SHALL be injectable through `FileUploadPluginOptions.sessionStore`.
#### Scenario: appendChunkIndex is idempotent
- **WHEN** `appendChunkIndex(id, 3)` is called twice
- **THEN** the resulting `receivedChunks` set contains exactly one `3`
**Verified by:** `src/session-store/memory.test.ts::"appendChunkIndex is idempotent"` AND `src/session-store/memory.test.ts::"create/read/update/delete round-trip"` AND `src/session-store/memory.test.ts::"listExpired returns sessions past TTL"`
---
### Requirement: Every UPLOAD_* code is thrown from a production path
`UPLOAD_CODES` SHALL define exactly 11 codes (`INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `MIME_REJECTED`, `TOO_LARGE`, `CHUNK_OUT_OF_ORDER`, `HASH_MISMATCH`, `SESSION_EXPIRED`, `STORAGE_FAILURE`, `INCOMPLETE`, `SERVICE_NOT_AVAILABLE`). Every code SHALL be raised by at least one production call site exercised by tests.
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every UPLOAD_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry instruments emit per operation
The package SHALL register counters `upload.singleshot_total{outcome}`, `upload.chunks_total{outcome}`, `upload.completes_total{outcome}`, `upload.bytes_total` and histogram `upload.complete_duration_ms`. Counters fire on every code path (success and failure); the histogram fires on every `complete` invocation regardless of outcome.
#### Scenario: counter + histogram emit on complete
- **WHEN** a chunked upload is completed against a real in-memory `MeterProvider`
- **THEN** both `upload.completes_total{outcome="success"}` AND `upload.complete_duration_ms` have observations
**Verified by:** `src/otel/instruments.test.ts::"complete emits counter + histogram"` AND `src/otel/instruments.test.ts::"singleshot emits counter and bytes"`
---
### Requirement: Server-tier package shape and 100% coverage
The package SHALL run in Bun + Node. `package.json` SHALL declare per-feature subpath exports for `./plugin/plugin`, `./error-codes/error-codes`, `./otel/instruments`, `./storage/memory`, `./storage/file-system`, `./session-store/memory`, `./services/file-upload-service`, `./assembler/assembler`, `./validators/mime`, `./validators/size`, `./routes/routes`, `./schemas/options`, `./schemas/routes`, `./types/types`. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. Feature barrels SHALL use explicit named re-exports (no `export *`). 100/100/100/100 coverage.
#### Scenario: subpath exports resolve
- **WHEN** `tests/dep-graph.test.ts` walks `package.json` exports
- **THEN** every subpath maps to an existing `src/` file
**Verified by:** `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"` AND `tests/dep-graph.test.ts::"package.json carries required dependencies"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,213 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-graphql (add-api-plugin-graphql)
**Package**: `@suaveplan/api-plugin-graphql`
**Status**: ACCEPTED
## ADDED Requirements
### Requirement: defineGraphQLPlugin produces a typed GraphQLPluginDefinition with defaulted config
The package SHALL expose `defineGraphQLPlugin(options)` returning a `GraphQLPluginDefinition` whose `config` field has `basePath`, `enableIntrospection`, `enablePlayground`, and `maxDepth` populated from caller-supplied values when present and from the package defaults (`/graphql`, `true`, `true`, `10`) otherwise. The returned object SHALL expose `name`, `config`, `hooks`, `getSchema()`, and `getResolvers()`.
#### Scenario: defaults applied when options omit config fields
- **WHEN** caller invokes `defineGraphQLPlugin({ name, version, schema, resolvers })` with no `basePath` / `enableIntrospection` / `enablePlayground` / `maxDepth`
- **THEN** `definition.config.basePath === "/graphql"`, `enableIntrospection === true`, `enablePlayground === true`, `maxDepth === 10`
#### Scenario: caller overrides defaults
- **WHEN** caller passes explicit values for all four
- **THEN** `definition.config` reflects every override verbatim
#### Scenario: lifecycle hooks pass through unchanged
- **WHEN** caller supplies `hooks: { onInit, onReady, onClose }`
- **THEN** `definition.hooks` exposes the same callable references
**Verified by:** `src/plugin/plugin.test.ts::"should create a plugin with default config"` AND `src/plugin/plugin.test.ts::"should override default config values"` AND `src/plugin/plugin.test.ts::"should include lifecycle hooks"` AND `src/plugin/plugin.test.ts::"should handle missing hooks gracefully"` AND `src/plugin/plugin.test.ts::"should include onClose hook"` AND `src/plugin/plugin.test.ts::"should return schema via getSchema"` AND `src/plugin/plugin.test.ts::"should return resolvers via getResolvers"` AND `src/plugin/plugin.test.ts::"should include version in config"`
---
### Requirement: defineGraphQLPlugin Zod-validates its options at the boundary
The package SHALL run `DefineGraphQLPluginOptionsSchema.safeParse(options)` inside `defineGraphQLPlugin` and SHALL throw `GraphQLError(GRAPHQL.INVALID_INPUT)` carrying the `ZodError` as `cause` when validation fails.
#### Scenario: invalid options reject
- **WHEN** caller passes a value missing `name` / `version` / `schema` / `resolvers` or with a non-string `name`
- **THEN** the call throws `GraphQLError` with `error.code === "GRAPHQL.INVALID_INPUT"` and `error.cause instanceof z.ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"throws GRAPHQL.INVALID_INPUT when options fail Zod validation"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INVALID_INPUT"`
---
### Requirement: SchemaBuilder constructs SchemaDefinition without raw SDL
The package SHALL expose `SchemaBuilder` with `addType`, `addInputType`, `addEnumType`, `addInterfaceType`, `addUnionType`, `addQuery`, `addMutation`, `addSubscription`, `hasType`, `getType`, the read-only counters (`typeCount`, `queryCount`, `mutationCount`), and `build()` returning a `SchemaDefinition` whose `types` / `queries` / `mutations` / `subscriptions` arrays are independent snapshots.
#### Scenario: object / input / enum / interface / union types
- **WHEN** the caller invokes each `add*Type` method
- **THEN** `getType(name).kind` returns the matching kind ("object" | "input" | "enum" | "interface" | "union")
#### Scenario: query / mutation / subscription registration
- **WHEN** caller invokes `addQuery` / `addMutation` / `addSubscription` with `required`, `isList`, `description`, `args` options
- **THEN** the corresponding entry in `build().{queries,mutations,subscriptions}` carries every option verbatim, with `required` / `isList` defaulting to `false`
#### Scenario: description + interfaces on object type
- **WHEN** `addType(name, builder, { description, interfaces })` runs
- **THEN** `getType(name)` returns `{description, interfaces}` matching the input
**Verified by:** `src/schema-builder/schema-builder.test.ts::"should add an object type"` AND `src/schema-builder/schema-builder.test.ts::"should support description and interfaces"` AND `src/schema-builder/schema-builder.test.ts::"should build type with correct kind"` AND `src/schema-builder/schema-builder.test.ts::"should add an input type"` AND `src/schema-builder/schema-builder.test.ts::"should support description"` AND `src/schema-builder/schema-builder.test.ts::"should add an enum type"` AND `src/schema-builder/schema-builder.test.ts::"should support description (enum)"` AND `src/schema-builder/schema-builder.test.ts::"should add an interface type"` AND `src/schema-builder/schema-builder.test.ts::"should support description (interface)"` AND `src/schema-builder/schema-builder.test.ts::"should add a union type"` AND `src/schema-builder/schema-builder.test.ts::"should support description (union)"` AND `src/schema-builder/schema-builder.test.ts::"should add a query field"` AND `src/schema-builder/schema-builder.test.ts::"should support args and description"` AND `src/schema-builder/schema-builder.test.ts::"should default required and isList to false"` AND `src/schema-builder/schema-builder.test.ts::"should add a mutation field"` AND `src/schema-builder/schema-builder.test.ts::"should support full options on mutation"` AND `src/schema-builder/schema-builder.test.ts::"should add a subscription field"` AND `src/schema-builder/schema-builder.test.ts::"should support full options on subscription"` AND `src/schema-builder/schema-builder.test.ts::"should return undefined for unknown type"` AND `src/schema-builder/schema-builder.test.ts::"should produce a complete schema definition"`
---
### Requirement: TypeBuilder chains field declarations
The package SHALL expose `TypeBuilder` with a `field(name, type, options?)` chainable method, a `fieldCount` getter, and a `build()` that returns a `TypeDefinition` with the configured `name`, `kind`, and an independent `fields` array. `required` and `isList` SHALL default to `false`.
#### Scenario: field with every option
- **WHEN** caller calls `field("items", "Item", { required: true, isList: true, description, defaultValue, args })`
- **THEN** the resulting `TypeDefinition.fields[0]` carries every option verbatim
#### Scenario: defaults
- **WHEN** caller calls `field("x", "String")` with no options
- **THEN** the resulting field has `required === false` and `isList === false`
**Verified by:** `src/type-builder/type-builder.test.ts::"should build fields with all options"` AND `src/type-builder/type-builder.test.ts::"should default required and isList to false"`
---
### Requirement: ResolverComposer composes middleware around resolver functions
The package SHALL expose `ResolverComposer` with `use(mw)`, `useForType(type, mw)`, `addResolver(type, field, fn)`, `addResolvers(type, map)`, `hasResolver(type, field)`, `typeCount`, `resolverCount`, and `build()` returning a `ResolverMap`. Composition SHALL apply global middleware first, then per-type middleware, then the resolver — equivalent to right-to-left wrapping. Async resolvers SHALL be awaited transparently through the chain.
#### Scenario: addResolver / addResolvers / hasResolver
- **WHEN** caller registers resolvers via either method
- **THEN** `hasResolver(type, field)` returns `true` for every registered slot and `false` otherwise
#### Scenario: global middleware chain
- **WHEN** two global middlewares wrap a resolver
- **THEN** invoking the built resolver calls middleware-1, middleware-2, the resolver, in that order
#### Scenario: per-type middleware only applies to that type
- **WHEN** `useForType("Mutation", mw)` is set and both Query and Mutation resolvers run
- **THEN** the middleware fires only for the Mutation resolver
#### Scenario: combined global + type middleware
- **WHEN** both a global and a type middleware are set
- **THEN** global runs before type, type runs before resolver
#### Scenario: async resolver awaited
- **WHEN** the registered resolver returns a Promise
- **THEN** `await map.Query.fieldName(...)` resolves to the awaited value
**Verified by:** `src/resolver/resolver.test.ts::"should add a resolver"` AND `src/resolver/resolver.test.ts::"should return false for non-existent resolver"` AND `src/resolver/resolver.test.ts::"should add multiple resolvers for a type"` AND `src/resolver/resolver.test.ts::"should build resolver map"` AND `src/resolver/resolver.test.ts::"should handle async resolvers"` AND `src/resolver/resolver.test.ts::"should apply global middleware to all resolvers"` AND `src/resolver/resolver.test.ts::"should chain multiple global middlewares"` AND `src/resolver/resolver.test.ts::"should apply type middleware only to that type"` AND `src/resolver/resolver.test.ts::"should apply global then type middleware"`
---
### Requirement: requireRoles middleware enforces authentication and role intersection
The package SHALL expose `requireRoles(...roles)` returning a `ResolverMiddleware` that throws `GraphQLError(GRAPHQL.UNAUTHENTICATED)` when `context.user` is absent and `GraphQLError(GRAPHQL.INSUFFICIENT_ROLE)` when the user's `roles` do not intersect the required set. When the check passes, control SHALL delegate to `next(parent, args, context, info)` unchanged.
#### Scenario: authenticated user with required role passes
- **WHEN** `context.user.roles` contains one of the required roles
- **THEN** the resolver runs and its return value flows through
#### Scenario: authenticated user with any-of roles passes
- **WHEN** the required set is `["admin", "editor"]` and the user has only `"editor"`
- **THEN** the resolver runs
#### Scenario: missing user throws UNAUTHENTICATED
- **WHEN** `context.user` is undefined
- **THEN** the middleware throws `GraphQLError` with `code === "GRAPHQL.UNAUTHENTICATED"`
#### Scenario: insufficient role throws INSUFFICIENT_ROLE
- **WHEN** `context.user.roles` shares no element with the required set
- **THEN** the middleware throws `GraphQLError` with `code === "GRAPHQL.INSUFFICIENT_ROLE"`
**Verified by:** `src/resolver/resolver.test.ts::"should pass when user has required role"` AND `src/resolver/resolver.test.ts::"should pass when user has any of required roles"` AND `src/resolver/resolver.test.ts::"should throw GRAPHQL.UNAUTHENTICATED when user is not authenticated"` AND `src/resolver/resolver.test.ts::"should throw GRAPHQL.INSUFFICIENT_ROLE when user lacks required roles"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.UNAUTHENTICATED"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INSUFFICIENT_ROLE"`
---
### Requirement: validateArgs middleware enforces argument validation
The package SHALL expose `validateArgs(validator)` returning a `ResolverMiddleware` that calls `validator(args)`. When the validator returns a non-null string, the middleware SHALL throw `GraphQLError(GRAPHQL.VALIDATION_FAILED)` carrying the validator's message; when it returns `null`, control SHALL pass to `next`.
#### Scenario: validator returns null
- **WHEN** the validator returns `null`
- **THEN** the resolver runs and its return value flows through
#### Scenario: validator returns an error string
- **WHEN** the validator returns `"name is required"`
- **THEN** the middleware throws `GraphQLError` with `code === "GRAPHQL.VALIDATION_FAILED"` and `.message` containing the validator's string
**Verified by:** `src/resolver/resolver.test.ts::"should pass when validation returns null"` AND `src/resolver/resolver.test.ts::"should throw GRAPHQL.VALIDATION_FAILED when validation returns error string"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.VALIDATION_FAILED"`
---
### Requirement: createQueryExecutedMiddleware emits graphql:query:executed event and OTel instruments
The package SHALL expose `createQueryExecutedMiddleware(bus?)` returning a `ResolverMiddleware` that times resolver execution and emits a typed `graphql:query:executed` event onto the supplied `EventBus<GraphQLEventMap>` (or `graphqlEvents` when no bus is supplied). The payload SHALL include `operationName` (`info.fieldName`), `operationType` (`info.parentType`), `durationMs`, `hasErrors`, and `timestamp`. The middleware SHALL increment `graphql.resolvers_executed_total{outcome}` and record `graphql.resolver_duration_ms` for every invocation regardless of success or failure.
#### Scenario: successful invocation emits event with hasErrors=false
- **WHEN** the wrapped resolver returns normally
- **THEN** a `graphql:query:executed` event is observed with `hasErrors === false`, `operationName === info.fieldName`, and `durationMs >= 0`
#### Scenario: thrown resolver emits event with hasErrors=true and re-throws
- **WHEN** the wrapped resolver throws
- **THEN** the same event type fires with `hasErrors === true` AND the original error propagates
#### Scenario: OTel instruments record under real MeterProvider
- **WHEN** a real in-memory `MeterProvider` is installed and the middleware runs once successfully and once with a throw
- **THEN** `graphql.resolvers_executed_total` exposes data points with `outcome=success` and `outcome=error` AND `graphql.resolver_duration_ms` exposes histogram data points
**Verified by:** `src/resolver/resolver.test.ts::"createQueryExecutedMiddleware emits graphql:query:executed on success"` AND `src/resolver/resolver.test.ts::"createQueryExecutedMiddleware emits graphql:query:executed and re-throws on error"` AND `src/resolver/resolver.test.ts::"createQueryExecutedMiddleware honors a caller-supplied EventBus"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"graphql.resolvers_executed_total + resolver_duration_ms recorded via real MeterProvider"`
---
### Requirement: error-codes registry exposes every GRAPHQL.* code as a thrown failure
The package SHALL define `GRAPHQL_CODES = defineCodes("GRAPHQL", [...])` with the six codes `UNAUTHENTICATED`, `INSUFFICIENT_ROLE`, `VALIDATION_FAILED`, `INVALID_INPUT`, `TYPE_NOT_FOUND`, `DUPLICATE_RESOLVER`. Every code SHALL be thrown from at least one production path. `GraphQLError extends SuaveplanError<GraphQLErrorCode>`.
#### Scenario: every code is reachable
- **WHEN** the conformance test triggers each documented failure path
- **THEN** every `GRAPHQL.*` code appears as the `code` field on a caught `GraphQLError`
**Verified by:** `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.UNAUTHENTICATED"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INSUFFICIENT_ROLE"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.VALIDATION_FAILED"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.INVALID_INPUT"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.TYPE_NOT_FOUND"` AND `src/api-plugin-graphql.spec-conformance.test.ts::"GRAPHQL.DUPLICATE_RESOLVER"`
---
### Requirement: type surface is preserved and the public API is generic-safe
The package SHALL export every type declared by upstream verbatim: `SchemaDefinition`, `TypeDefinition`, `TypeKind`, `FieldDefinition`, `ScalarType`, `QueryContext`, `ResolverFn`, `ResolverInfo`, `ResolverMiddleware`, `ResolverMap`, `GraphQLPluginConfig`, `GraphQLPluginHooks`, `GraphQLQueryExecutedEvent`, `GraphQLEventMap`, `DefineGraphQLPluginOptions`, `GraphQLPluginDefinition`. The `EventBus<GraphQLEventMap>` typing SHALL flow `graphql:query:executed` payloads through `bus.on(...)` listeners without casts.
#### Scenario: type-level event payload narrows
- **WHEN** `expectTypeOf` introspects `graphqlEvents.on("graphql:query:executed", listener)`
- **THEN** `listener` is typed `(payload: GraphQLQueryExecutedEvent) => void | Promise<void>`
**Verified by:** `src/api-plugin-graphql.spec-conformance.test.ts::"public types are generic-safe and event payload is typed"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,437 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-import
**Package**: `@suaveplan/api-plugin-import`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line pointing at the
> `it(...)` titles in the package's test suite.
## Requirements
### Requirement: Stable IMPORT.* error registry
The package SHALL register exactly nine stable error codes under the `"IMPORT"` namespace via `defineCodes("IMPORT", […])`, exporting `IMPORT_CODES` and `ImportError extends SuaveplanError<ImportErrorCode>`. Every code SHALL be reachable from a real production code path; deletion to satisfy a coverage gate is forbidden.
#### Scenario: registry declares the nine canonical codes
- **WHEN** a caller imports `IMPORT_CODES`
- **THEN** the literal keys equal `{NOT_FOUND, FORBIDDEN, VALIDATION, FILE_TOO_LARGE, UNSUPPORTED_FORMAT, PROCESSING_FAILED, STORAGE_ERROR, TIMEOUT, INVALID_OPTIONS}` and every value is namespaced `"IMPORT.<KEY>"`
#### Scenario: every code is thrown from a production path
- **WHEN** the test suite drives the full feature surface
- **THEN** every key in `IMPORT_CODES` appears as `error.code` on at least one captured throw
**Verified by:** `src/error-codes/error-codes.test.ts::"declares all nine IMPORT codes"` AND `src/error-codes/error-codes.test.ts::"every IMPORT code is reachable from a production path"`
---
### Requirement: CSV parsing — quoted, custom-delimiter, header-aware
The package SHALL parse CSV input into `Record<string, string>[]` rows respecting `"`-quoted fields (including escaped `""` quotes and embedded newlines), custom delimiters, optional headers (defaulting to `column_N` placeholders when absent), and SHALL expose a streaming variant (`streamParseCsv`) that surfaces per-row validation errors without aborting the run.
#### Scenario: header-first CSV with quoted comma
- **WHEN** input is `name,city\n"Doe, Jane",NYC`
- **THEN** the result is `[{ name: "Doe, Jane", city: "NYC" }]`
#### Scenario: streaming parser collects validation errors
- **WHEN** `streamParseCsv` is invoked with `validate` returning `"bad"` on row 1
- **THEN** the result reports `errors.length === 1` with `row: 1, message: "bad"` and `validRows` excludes that row
**Verified by:** `src/parsers/csv.test.ts::"parses quoted fields with embedded commas"` AND `src/parsers/csv.test.ts::"streamParseCsv surfaces per-row validate errors"`
---
### Requirement: JSON parser rejects non-arrays
The package SHALL accept a JSON-array input string and return its elements as rows; non-array input SHALL throw with a message starting `JSON data must be an array`.
#### Scenario: valid JSON array
- **WHEN** input is `[{"a":1}]`
- **THEN** `parseJson(input)` returns `[{ a: 1 }]`
#### Scenario: invalid non-array input
- **WHEN** input is `{"a":1}`
- **THEN** `parseJson(input)` throws an `Error` whose message contains `JSON data must be an array`
**Verified by:** `src/parsers/json.test.ts::"returns array elements as rows"` AND `src/parsers/json.test.ts::"throws when input is not a JSON array"`
---
### Requirement: XLSX (TSV-style) parser
The package SHALL provide `parseXlsx` that treats `\t`-separated lines as rows with optional header inference; binary `.xlsx` decoding is explicitly out of scope and SHALL be documented as a limitation.
#### Scenario: TSV input with header row
- **WHEN** input is `a\tb\n1\t2`
- **THEN** `parseXlsx(input)` returns `{ headers: ["a","b"], rows: [{a:"1",b:"2"}], totalRows: 1 }`
**Verified by:** `src/parsers/xlsx.test.ts::"parses TSV with headers"` AND `src/parsers/xlsx.test.ts::"synthesises column_N headers when hasHeaders is false"`
---
### Requirement: Transform pipeline — coerce, validate, compute
The package SHALL provide a declarative `transformRow` / `transformRows` pipeline that applies `TransformMapping[]` (source→target with optional type coercion, default value, required flag, transform fn), `ComputedField[]` (target derived from named source columns), and `FieldValidation[]` (`required` / `min` / `max` / `pattern` / `custom`). `coerceType` SHALL succeed/fail explicitly for `number` / `boolean` / `date` / pass-through for `string`. Failed transforms SHALL push a `TransformError` and skip the field, never throw.
#### Scenario: required field missing produces TransformError
- **WHEN** `transformRow({}, 0, { mappings: [], validations: [{ field: "x", rule: "required" }] })` runs against an empty `result`
- **THEN** the result is `{ success: false, errors: [{ row: 0, field: "x", message: '"x" is required', value: undefined }] }`
#### Scenario: type coercion failure marks row failed
- **WHEN** `transformRow({ x: "abc" }, 0, { mappings: [{ sourceColumn: "x", targetField: "x", type: "number" }] })`
- **THEN** `errors` contains one entry with `message: "Cannot coerce to number"` and `result.x` is absent
#### Scenario: computed field aggregates source columns
- **WHEN** `transformRow({ a: 1, b: 2 }, 0, { mappings: [], computedFields: [{ targetField: "sum", sourceColumns: ["a","b"], compute: v => Number(v.a) + Number(v.b) }] })`
- **THEN** `row.sum === 3`
**Verified by:** `src/transform/pipeline.test.ts::"validateField required produces error for missing value"` AND `src/transform/pipeline.test.ts::"coerceType number returns ok:false for non-numeric input"` AND `src/transform/pipeline.test.ts::"computed field is evaluated from named source columns"`
---
### Requirement: ImportProgressTracker — rows/sec, ETA, percent
The package SHALL ship an `ImportProgressTracker` that records single + batch successes/failures, computes `percentComplete` (rounded int), `rowsPerSecond` (2dp), and `estimatedRemainingMs`, fans snapshots to registered listeners, and SHALL expose an `isComplete()` predicate that returns `true` iff `processedRows >= totalRows`.
#### Scenario: batch success updates totals and notifies listeners
- **WHEN** `tracker.recordBatchSuccess(5)` runs after a listener is registered
- **THEN** the listener receives a snapshot with `successCount: 5` and `processedRows: 5`
#### Scenario: zero totalRows yields zero percent
- **WHEN** `new ImportProgressTracker("j", 0).snapshot()` runs
- **THEN** `percentComplete === 0` and `rowsPerSecond` is finite (`0` or a small positive number)
**Verified by:** `src/progress/tracker.test.ts::"recordBatchSuccess fans a snapshot with the cumulative counts"` AND `src/progress/tracker.test.ts::"snapshot guards against zero totalRows"`
---
### Requirement: Async ImportStoreAdapter + MemoryImportStore
The package SHALL declare an async `ImportStoreAdapter` interface with `create` / `get` / `update` / `delete` / `list` and SHALL ship a `MemoryImportStore` implementing it. `update` on a missing id SHALL throw with `Job "<id>" not found`. `list` SHALL filter by `status` and respect `limit` / `offset`.
#### Scenario: round-trip create + get
- **WHEN** a record is created and then fetched by id
- **THEN** the fetched value equals the created one
#### Scenario: update on missing id throws
- **WHEN** `update("missing", {...})` runs on an empty store
- **THEN** it rejects with an error whose message matches `Job "missing" not found`
**Verified by:** `src/store/memory.test.ts::"round-trips a record via create and get"` AND `src/store/memory.test.ts::"throws when updating a missing id"`
---
### Requirement: ImportService — preview, startImport, owner-scoped jobs
The package SHALL ship a canonical `ImportService extends EventEmitter<ImportEventMap>` with `preview(format, data)`, `startImport(config, { ownerId })`, `getJob(id, ownerId)`, `getResults(id, ownerId)`, `listJobs(ownerId)`, `deleteJob(id, ownerId)`. Foreign-owner reads SHALL throw `IMPORT.NOT_FOUND` (anti-enumeration; never `IMPORT.FORBIDDEN` on lookup misses). Format validation SHALL throw `IMPORT.UNSUPPORTED_FORMAT`; parse failures SHALL throw `IMPORT.PROCESSING_FAILED`; missing mappings SHALL throw `IMPORT.VALIDATION`. The service SHALL emit `import.started`, `import.progress`, `import.completed`, `import.failed`, `import.row_error`, `import:row:processed` per the documented `ImportEventMap`. Successful runs SHALL increment `import.jobs_total{outcome:"success"}` and `import.rows_total` OTel counters.
#### Scenario: preview returns column auto-mapping
- **WHEN** `preview("csv", "a,b\n1,2")` runs
- **THEN** the returned `mappings` has `sourceColumn === targetField` for every detected column
#### Scenario: dry-run does not persist results
- **WHEN** `startImport({ format, data, mappings, dryRun: true }, { ownerId })` succeeds
- **THEN** `getResults(jobId, ownerId)` returns `[]`
#### Scenario: foreign-owner lookup returns 404 not 403
- **WHEN** `getJob(jobId, "other-user")` runs on a job owned by `"alice"`
- **THEN** it throws `ImportError` with `code === IMPORT_CODES.NOT_FOUND`
#### Scenario: parse failure surfaces as IMPORT.PROCESSING_FAILED
- **WHEN** `startImport({ format: "json", data: "not-json", mappings: [{ sourceColumn: "x", targetField: "x" }] })` runs
- **THEN** it throws `ImportError` with `code === IMPORT_CODES.PROCESSING_FAILED`
**Verified by:** `src/service/service.test.ts::"preview returns identity column mappings"` AND `src/service/service.test.ts::"dryRun does not persist imported rows"` AND `src/service/service.test.ts::"foreign owner lookup returns NOT_FOUND"` AND `src/service/service.test.ts::"parse failure throws PROCESSING_FAILED"`
---
### Requirement: 5 Hono routes, auth-gated, owner-scoped
The package SHALL ship `createImportRoutes({ service, instruments })` returning an `OpenAPIHono` with five routes — `POST /preview`, `POST /`, `GET /`, `GET /{id}`, `DELETE /{id}`. Every route SHALL be wrapped in `requireAuth()` from `@suaveplan/api-plugin-auth`. Job-scoped routes SHALL read `c.get("user").id` as `ownerId`. Lookup misses on jobs owned by a different user SHALL return HTTP 404 with RFC 7807 problem+json (anti-enumeration).
#### Scenario: anonymous request to POST / returns 401
- **WHEN** a request reaches `POST /` with `c.get("user") === null`
- **THEN** the response is HTTP 401 with `content-type: application/problem+json`
#### Scenario: foreign-owner GET /{id} returns 404
- **WHEN** alice owns job `j1` and bob calls `GET /j1`
- **THEN** the response is HTTP 404 with problem+json
**Verified by:** `src/routes/build-routes.test.ts::"POST / returns 401 when caller is anonymous"` AND `src/routes/build-routes.test.ts::"GET /{id} returns 404 when the job is owned by a different user"`
---
### Requirement: RFC 7807 problem+json mapping
The package SHALL provide `respondProblemJson(c, err)` that renders any `SuaveplanError` as RFC 7807 `application/problem+json`, reading `error.context.statusCode` when present and defaulting to 500. Unknown throwables SHALL render as a generic 500.
#### Scenario: SuaveplanError with statusCode 404
- **WHEN** the error carries `context.statusCode = 404`
- **THEN** the response status is 404 and the body is JSON with the `code` and `message` fields
#### Scenario: unknown throwable yields 500
- **WHEN** a plain `string` is thrown
- **THEN** the response status is 500 with `content-type: application/problem+json`
**Verified by:** `src/routes/problem-json.test.ts::"renders SuaveplanError with the context statusCode"` AND `src/routes/problem-json.test.ts::"renders unknown throwables as a generic 500"`
---
### Requirement: OTel instruments bundle
The package SHALL export `getImportInstruments()` returning a cached bundle of four OpenTelemetry instruments — `import.jobs_total` (Counter, labelled by `outcome`), `import.rows_total` (Counter, labelled by `outcome`), `import.row_errors_total` (Counter), `import.duration_ms` (Histogram). `resetImportInstruments()` SHALL drop the cache so subsequent calls rebind against the current global `MeterProvider`. Production code paths SHALL emit on every `startImport`.
#### Scenario: cached bundle returns same reference
- **WHEN** `getImportInstruments()` is called twice without reset
- **THEN** the second call returns the same object
#### Scenario: emit after install collects metrics
- **WHEN** a fresh `MeterProvider` is installed, `resetImportInstruments()` runs, and a counter is incremented
- **THEN** the in-memory exporter contains the metric
**Verified by:** `src/otel/instruments.test.ts::"getImportInstruments returns a cached bundle"` AND `src/otel/instruments.test.ts::"resetImportInstruments rebinds against a fresh MeterProvider"`
---
### Requirement: createImportPlugin descriptor
The package SHALL export `createImportPlugin(options?)` returning a typed `PluginDescriptor<ImportPluginConfig>` whose `name: "import"`, `version: "0.1.0"`, `optionalDependencies: ["auth", "events", "database"]`, and `tags: ["import", "csv", "json", "xlsx"]`. Invalid options SHALL throw `ImportError(IMPORT.INVALID_OPTIONS)` with the `ZodError` as `cause`. `onInit` SHALL register the `ImportService` instance on `ServiceRegistry["importService"]`; `onReady` SHALL mount routes under the configured `routePrefix`; `onClose` SHALL clear all listeners on the service.
#### Scenario: bad options throws INVALID_OPTIONS
- **WHEN** `createImportPlugin({ routePrefix: "" })` runs
- **THEN** it throws `ImportError` with `code === IMPORT_CODES.INVALID_OPTIONS` and `cause instanceof ZodError`
#### Scenario: onInit registers the service slot
- **WHEN** `descriptor.onInit(ctx)` runs
- **THEN** `ctx.getService("importService")` is defined and is an `ImportService` instance
#### Scenario: onReady mounts routes under routePrefix
- **WHEN** `descriptor.onReady(ctx)` runs with `routePrefix: "/imports"`
- **THEN** the host `app.routes` contains an entry whose path starts with `/imports/`
**Verified by:** `src/plugin/plugin.test.ts::"throws INVALID_OPTIONS on bad config"` AND `src/plugin/plugin.test.ts::"onInit registers the importService slot"` AND `src/plugin/plugin.test.ts::"onReady mounts routes under the configured routePrefix"` AND `src/plugin/plugin.spec-conformance.test.ts::"descriptor carries the canonical metadata"`
---
### Requirement: Subpath exports per feature module
The package SHALL declare a subpath export in `package.json#exports` for every feature module so consumers can tree-shake. The package root SHALL re-export public symbols via explicit named re-exports; feature-module barrels SHALL NOT use `export *`.
#### Scenario: subpath export count matches feature count
- **WHEN** the `exports` map is enumerated
- **THEN** it contains entries for `./error-codes/error-codes`, `./parsers/csv`, `./parsers/json`, `./parsers/xlsx`, `./transform/pipeline`, `./progress/tracker`, `./store/memory`, `./service/service`, `./routes/problem-json`, `./routes/build-routes`, `./otel/instruments`, `./schemas/options`, `./schemas/schemas`, `./plugin/plugin`, `./types/types`
#### Scenario: feature barrels use explicit named re-exports
- **WHEN** the file tree is scanned for `export *` in `src/<feature>/index.ts`
- **THEN** no matches exist (the conformance script enforces this)
**Verified by:** `src/plugin/plugin.spec-conformance.test.ts::"package exposes a subpath export for every feature module"` AND `src/plugin/plugin.spec-conformance.test.ts::"every feature barrel uses explicit named re-exports"`
---
### Requirement: SurrealImportStore — durable ImportStoreAdapter
The package SHALL ship `SurrealImportStore implements ImportStoreAdapter` at `src/store/surreal/surreal-import-store.ts`, providing the same `create` / `get` / `update` / `delete` / `list` surface as `MemoryImportStore` against a `SurrealAdapter` backend from `@suaveplan/surrealdb`. All SurrealQL placeholders SHALL be positional (`$p0`, `$p1`, …) per the canonical pattern in `@suaveplan/api-plugin-events`. The constructor SHALL accept a `SurrealImportStoreConfig` validated through Zod at the boundary; an invalid config SHALL throw `ImportError` with `code === IMPORT_CODES.INVALID_OPTIONS` and `cause instanceof ZodError`. The store SHALL accept a custom `table` name (default `"import_job"`). `create` SHALL upsert the record by stringified id; `update` on a missing id SHALL throw an error whose message contains `Job "<id>" not found` (parity with `MemoryImportStore`). Adapter-layer failures SHALL be wrapped as `ImportError(IMPORT_CODES.STORAGE_ERROR)` with the underlying error as `cause`. The store SHALL increment `import.store.writes_total{outcome:"success"|"error"}` on every `create` / `update` / `delete`, and `import.store.reads_total{outcome:"success"|"error"}` on every `get` / `list`.
#### Scenario: create emits a CREATE SET statement with positional params
- **WHEN** `surrealStore.create(record)` runs with `record.id = "j1"`
- **THEN** the issued SurQL begins with `CREATE import_job:j1 SET` and the parameter array contains the record's `ownerId`, `status`, numeric counters, and timestamp strings in the order they appear in the `SET` clause
#### Scenario: get returns undefined when no row exists
- **WHEN** `surrealStore.get("missing")` runs against an empty backend
- **THEN** the call resolves to `undefined` and `import.store.reads_total{outcome:"success"}` is incremented (a miss is still a successful read)
#### Scenario: update on missing id throws Job "<id>" not found
- **WHEN** `surrealStore.update("missing", { status: "completed" })` runs on a store with no record `"missing"`
- **THEN** the call rejects with an `Error` whose message contains `Job "missing" not found`
#### Scenario: list filters by status, ownerId, limit, offset
- **WHEN** `surrealStore.list({ status: "completed", ownerId: "alice", limit: 10, offset: 5 })` runs
- **THEN** the SurQL contains `WHERE status = $p0 AND owner_id = $p1`, `LIMIT $p2`, `START $p3` and the parameter array is `["completed", "alice", 10, 5]`
#### Scenario: adapter failure surfaces as STORAGE_ERROR
- **WHEN** the underlying `SurrealAdapter.query` throws
- **THEN** the store's call rejects with an `ImportError` whose `code === IMPORT_CODES.STORAGE_ERROR` and `cause` is the original throwable
#### Scenario: invalid config throws INVALID_OPTIONS
- **WHEN** `new SurrealImportStore({ db: adapter, table: "" })` runs
- **THEN** it throws `ImportError` with `code === IMPORT_CODES.INVALID_OPTIONS` and `cause instanceof ZodError`
**Verified by:** `src/store/surreal/surreal-import-store.test.ts::"create issues a CREATE SET statement with positional params"` AND `src/store/surreal/surreal-import-store.test.ts::"get returns undefined when no row exists"` AND `src/store/surreal/surreal-import-store.test.ts::"update on missing id throws Job not found"` AND `src/store/surreal/surreal-import-store.test.ts::"list filters by status, ownerId, limit, offset"` AND `src/store/surreal/surreal-import-store.test.ts::"adapter failure surfaces as STORAGE_ERROR on read"` AND `src/store/surreal/surreal-import-store.test.ts::"invalid config throws INVALID_OPTIONS"`
---
### Requirement: SurrealDB schema migration
The package SHALL ship `surql/tables/import_job.surql` defining the `import_job` table SCHEMAFULL with fields `owner_id (string)`, `status (string)` constrained to the `ImportJobStatus` enum, `total_rows (int)`, `processed_rows (int)`, `success_count (int)`, `error_count (int)`, `errors (array)`, `metadata (option<object>)`, `created_at (datetime)`, `updated_at (datetime)`, `completed_at (option<datetime>)`, plus indexes on `owner_id`, `status`, and `created_at`. The migration SHALL use the canonical `DEFINE TABLE OVERWRITE` / `DEFINE FIELD OVERWRITE` form mirroring `packages/api/api-plugin-events/surql/tables/event.surql`. Hosts MAY apply the migration via `@suaveplan/api-plugin-database`'s migration runner or directly via `adapter.query(readFileSync(...))`.
#### Scenario: migration declares the canonical schema
- **WHEN** the integration test reads and applies `surql/tables/import_job.surql`
- **THEN** the SurrealDB instance subsequently accepts a `CREATE import_job:j1 SET owner_id = 'alice', status = 'created', …` statement and rejects a `status = 'bogus'` write
**Verified by:** `tests/integration/surreal-import-store.test.ts::"migration enables canonical writes and rejects invalid status"`
---
### Requirement: Integration round-trip against a real SurrealDB instance
The package SHALL ship `tests/integration/surreal-import-store.test.ts` using `SurrealDBFixture` from `@suaveplan/testing-integration-surrealdb`. The test SHALL apply the `.surql` migration once in `beforeAll`, then `create` a record, `get` it back, `update` it, `list` with a `status` filter, and `delete` it — all against the real container. The integration test SHALL also assert that `update("missing", …)` rejects with the `Job "missing" not found` message and that the `errors` array round-trips intact.
#### Scenario: full round-trip against testcontainer
- **WHEN** the integration test runs against a started `SurrealDBFixture`
- **THEN** each store operation completes successfully and the final `get` after `delete` returns `undefined`
**Verified by:** `tests/integration/surreal-import-store.test.ts::"round-trips a record through the real SurrealDB instance"` AND `tests/integration/surreal-import-store.test.ts::"update on missing id surfaces the canonical message against a real instance"`
---
### Requirement: OTel store counters
The package SHALL extend `getImportInstruments()` to expose two additional counters — `import.store.writes_total` and `import.store.reads_total` — both labelled by `outcome`. The counter pair SHALL be cached on the same bundle as the existing four instruments and SHALL rebind through `resetImportInstruments()`. `SurrealImportStore` SHALL emit on every adapter call; `MemoryImportStore` is out of scope for this requirement (it is an in-process test/dev adapter without observable backend health).
#### Scenario: bundle exposes the two new counters
- **WHEN** a caller invokes `getImportInstruments()`
- **THEN** the returned object exposes `storeWritesTotal` and `storeReadsTotal` Counter instruments in addition to the existing four
**Verified by:** `src/otel/instruments.test.ts::"bundle exposes the store write and read counters"`
---
### Requirement: Subpath export for the SurrealDB store
The package SHALL declare the subpath export `./store/surreal/surreal-import-store` in `package.json#exports` so consumers can tree-shake the SurrealDB store without pulling in the in-memory implementation. The `src/store/surreal/index.ts` barrel SHALL use explicit named re-exports (no `export *`).
#### Scenario: subpath export exists and the barrel uses named exports
- **WHEN** the package's `exports` map is enumerated and the new barrel is read
- **THEN** the map contains `./store/surreal/surreal-import-store` and the barrel contains no `export *` lines
**Verified by:** `src/plugin/plugin.spec-conformance.test.ts::"package exposes a subpath export for the SurrealDB store"`
---
### Requirement: SSE progress push — `GET /imports/:id/progress`
The package SHALL expose `GET /imports/:id/progress` returning
`Content-Type: text/event-stream; charset=utf-8`, mounted under the existing
`requireAuth()` middleware. Frames SHALL be driven directly off the existing
`ImportService.bus` events (`import.progress`, `import.completed`, `import.failed`);
NO polling loop over the store is permitted. Every frame SHALL carry the canonical
`ImportProgress` JSON payload. Three SSE event kinds SHALL be emitted: `progress`
(every `import.progress` tick), `complete` (terminal, on `import.completed`),
and `failed` (terminal, on `import.failed`). The stream SHALL close cleanly after
a terminal event. The handler SHALL respect the request's `AbortSignal` (client
disconnect) by detaching the bus listener and incrementing
`import.sse.disconnected{reason: "client"}`. Foreign-owner or missing-job lookups
SHALL return HTTP 404 RFC 7807 problem+json with `code === IMPORT.NOT_FOUND`
(anti-enumeration parity with `GET /:id`). Anonymous requests SHALL return HTTP
401 problem+json via the existing auth middleware.
The package SHALL extend `getImportInstruments()` with three additional counters:
`import.sse.connected` (Counter), `import.sse.events_sent` (Counter, labelled by
`kind ∈ "progress" | "complete" | "failed"`), `import.sse.disconnected`
(Counter, labelled by `reason ∈ "complete" | "failed" | "client"`). The counters
SHALL be cached on the same bundle as the existing six instruments and SHALL
rebind through `resetImportInstruments()`.
#### Scenario: opens a text/event-stream and emits an initial progress frame
- **WHEN** an authenticated caller `GET /imports/:id/progress` for a job they own
- **THEN** the response is HTTP 200 with `content-type: text/event-stream; charset=utf-8`
and the first frame is `event: progress\ndata: <ImportProgress JSON>\n\n`
#### Scenario: emits a progress frame for every bus progress event
- **WHEN** the service emits `import.progress` while the client is connected
- **THEN** the stream writes a frame whose first non-id line is `event: progress`
and whose `data:` line decodes to an `ImportProgress` object with the matching
`processedRows`
#### Scenario: emits a complete frame and closes on import.completed
- **WHEN** the service emits `import.completed`
- **THEN** the stream writes a frame whose first non-id line is `event: complete`
and then closes; subsequent reads from the stream return `done: true`
#### Scenario: emits a failed frame and closes on import.failed
- **WHEN** the service emits `import.failed`
- **THEN** the stream writes a frame whose first non-id line is `event: failed`
and then closes
#### Scenario: returns 404 problem+json for a foreign-owner job
- **WHEN** alice owns job `j1` and bob requests `GET /imports/j1/progress`
- **THEN** the response is HTTP 404 with `content-type: application/problem+json`
and a body whose `code === "IMPORT.NOT_FOUND"`
#### Scenario: returns 401 problem+json when the caller is anonymous
- **WHEN** an anonymous caller requests `GET /imports/:id/progress`
- **THEN** the response is HTTP 401 with `content-type: application/problem+json`
#### Scenario: detaches the listener and increments disconnect counter on client abort
- **WHEN** the client aborts the request before a terminal event
- **THEN** the handler unsubscribes from the bus and increments
`import.sse.disconnected{reason:"client"}`
#### Scenario: increments import.sse counters on connect, send, and disconnect
- **WHEN** a connection opens, two progress events fire, and a terminal `complete`
event closes the stream
- **THEN** `import.sse.connected` increments by 1, `import.sse.events_sent{kind}`
increments by 3 (2× `progress` + 1× `complete`), and
`import.sse.disconnected{reason:"complete"}` increments by 1
**Verified by:**
`src/routes/progress-sse/progress-sse.test.ts::"opens a text/event-stream and emits an initial progress frame"`
AND `src/routes/progress-sse/progress-sse.test.ts::"emits a progress frame for every bus progress event"`
AND `src/routes/progress-sse/progress-sse.test.ts::"emits a complete frame and closes on import.completed"`
AND `src/routes/progress-sse/progress-sse.test.ts::"emits a failed frame and closes on import.failed"`
AND `src/routes/progress-sse/progress-sse.test.ts::"returns 404 problem+json for a foreign-owner job"`
AND `src/routes/progress-sse/progress-sse.test.ts::"returns 401 problem+json when the caller is anonymous"`
AND `src/routes/progress-sse/progress-sse.test.ts::"detaches the listener and increments disconnect counter on client abort"`
AND `src/routes/progress-sse/progress-sse.test.ts::"increments import.sse counters on connect, send, and disconnect"`
AND `src/otel/instruments.test.ts::"bundle exposes the SSE counters"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,565 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-notifications (add-api-plugin-notifications)
**Package**: `@suaveplan/api-plugin-notifications`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Plugin factory and lifecycle
The package SHALL export `createNotificationsPlugin(options?:
NotificationsPluginConfigInput, services?: NotificationsPluginServices)`
returning a `PluginDescriptor<NotificationsPluginConfig>` that mounts
both the `/notifications` and `/push` route groups against
`@suaveplan/api`. The factory SHALL Zod-validate input via
`NotificationsPluginOptionsSchema` and throw
`NotificationsPluginError(INVALID_CONFIG)` carrying the `ZodError`
as `cause` on invalid input. `onInit` SHALL register
`notificationRepository`, `deviceTokenRepository`, and (when
`enablePush`) `apnService` in the host's `ServiceRegistry`. When the
caller supplies `services.emailService`, `onInit` SHALL register it
under the `emailService` slot. When the config has `enableDispatcher:
true`, `onInit` SHALL construct a `NotificationDispatcher` wired with
every available channel (`apn` if `apnService` is registered, `email`
if `emailService` is registered) and register it under
`notificationDispatcher`; the dispatcher SHALL refuse to construct
when zero channels are available
(`NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`). `onReady`
SHALL mount both route trees and refuse to double-mount. `onClose`
SHALL shut down the APN transport (if any), null the dispatcher
reference, and clear runtime state.
**Verified by:** `src/plugin/plugin.test.ts`, `src/schemas/options.test.ts`,
`src/api-plugin-notifications.spec-conformance.test.ts`.
#### Scenario: valid options produce a descriptor
- **WHEN** `createNotificationsPlugin({ basePath: "/v1/notifications" })` is called
- **THEN** the descriptor has `name === "notifications"`, `dependencies === ["database"]`, and `defaults.basePath === "/v1/notifications"`
#### Scenario: invalid options fail fast
- **WHEN** `createNotificationsPlugin({ basePath: "" } as never)` is called
- **THEN** the call throws `NotificationsPluginError` with `code === NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG`
#### Scenario: onInit registers repositories
- **WHEN** `onInit(ctx)` runs with a real `db` service in the registry
- **THEN** `ctx.getService("notificationRepository")` and `ctx.getService("deviceTokenRepository")` resolve to non-null instances
#### Scenario: onInit fails without database service
- **WHEN** `onInit(ctx)` runs with no `db` service registered
- **THEN** it throws `NotificationsPluginError(SERVICE_NOT_AVAILABLE)`
#### Scenario: onInit registers APN when enabled
- **WHEN** `onInit(ctx)` runs with `enablePush === true` and a valid `apn` config
- **THEN** `ctx.getService("apnService")` resolves to an `ApnService` instance whose `isInitialized()` returns `true`
#### Scenario: onInit fails when enablePush without apn
- **WHEN** `onInit(ctx)` runs with `enablePush === true` and `apn === undefined`
- **THEN** it throws `NotificationsPluginError(APN_CONFIG_MISSING)`
#### Scenario: onInit registers notificationDispatcher when enableDispatcher is on
- **WHEN** `onInit(ctx)` runs with `enableDispatcher: true`, `enablePush: true`, and a valid APN config
- **THEN** `ctx.getService("notificationDispatcher")` resolves to a `NotificationDispatcher` whose `dispatch({ channel: "apn", … })` succeeds
#### Scenario: onInit refuses dispatcher with zero channels
- **WHEN** `onInit(ctx)` runs with `enableDispatcher: true`, `enablePush: false`, and no `emailService` injected
- **THEN** it throws `NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`
#### Scenario: onReady mounts both route trees
- **WHEN** `onReady(ctx)` runs after `onInit`
- **THEN** `ctx.getService("app").routes` contains entries whose `path` begins with the configured `basePath` AND with the configured `pushBasePath`
#### Scenario: onReady refuses to double-mount
- **WHEN** `onReady(ctx)` is invoked twice against the same app instance
- **THEN** the second call throws `NotificationsPluginError(ROUTE_PREFIX_COLLISION)`
#### Scenario: onClose clears repositories AND dispatcher
- **WHEN** `onClose()` is invoked after a successful boot with `enableDispatcher: true`
- **THEN** the dispatcher reference is nulled and subsequent `onReady` invocations against a fresh app raise `SERVICE_NOT_AVAILABLE`
### Requirement: NotificationRepository contract
The package SHALL export a `NotificationRepository` class implementing
`INotificationRepository` against the genesis `SurrealAdapter`. Every
statement SHALL use positional placeholders (`$p0`, `$p1`, …) matching
the `DatabaseAdapter.query(sql, params: readonly unknown[])` surface.
Twelve operations SHALL be present: `create`, `findById`,
`findByRecipient`, `getUnreadCount`, `update`, `markAsRead`,
`markManyAsRead`, `markAllAsRead`, `dismiss`, `delete`,
`findGroupedByType`, `deleteOldRead`. Record-id normalisation
SHALL accept both bare and prefixed ids; SurrealDB `RecordId` row
shapes SHALL collapse to string ids inside `transformRecord`.
**Verified by:** `src/repositories/notification-repository/notification-repository.test.ts`.
#### Scenario: create returns transformed row
- **WHEN** `repo.create({ type, recipient, content })` returns a fresh notification id
- **THEN** the returned `Notification.id` is a string, even when the adapter returned a `{ tb, id }` RecordId shape
#### Scenario: empty adapter row raises NOTIFICATION_CREATE_FAILED
- **WHEN** the adapter returns `[]` on CREATE
- **THEN** the repository throws `NotificationsPluginError(NOTIFICATION_CREATE_FAILED)`
#### Scenario: findByRecipient builds the SurrealQL filter
- **WHEN** `findByRecipient(userId, siteId, opts)` is called with every option
- **THEN** the SurQL string contains `WHERE recipient = type::thing('user', $p0)`, `AND site = type::thing('site', $p…)`, `AND read = false`, `AND type = $p…`, `AND created_at > $p…`, `LIMIT $p…`, and `START $p…`
#### Scenario: deleteOldRead rejects negative input
- **WHEN** `deleteOldRead(-1)` is invoked
- **THEN** the repository throws `NotificationsPluginError(INVALID_CONFIG)`
### Requirement: DeviceTokenRepository contract
The package SHALL export a `DeviceTokenRepository` class implementing
`IDeviceTokenRepository`. Eight operations SHALL be present:
`register`, `findById`, `findByToken`, `findActiveForUser`,
`deactivate`, `deactivateByToken`, `touchLastUsed`, `cleanup`.
Duplicate-token detection SHALL fold the SurrealDB UNIQUE-index
violation into `DEVICE_TOKEN_DUPLICATE`. Other adapter failures SHALL
fold into `DEVICE_TOKEN_CREATE_FAILED`.
**Verified by:** `src/repositories/device-token-repository/device-token-repository.test.ts`.
#### Scenario: duplicate token raises DEVICE_TOKEN_DUPLICATE
- **WHEN** the adapter throws an error mentioning `token` on CREATE
- **THEN** the repository throws `NotificationsPluginError(DEVICE_TOKEN_DUPLICATE)`
#### Scenario: generic create failure raises DEVICE_TOKEN_CREATE_FAILED
- **WHEN** the adapter throws an unrelated error on CREATE
- **THEN** the repository throws `NotificationsPluginError(DEVICE_TOKEN_CREATE_FAILED)`
#### Scenario: cleanup rejects negative daysOld
- **WHEN** `cleanup(-1)` is invoked
- **THEN** the repository throws `NotificationsPluginError(INVALID_CONFIG)`
### Requirement: ApnService with pluggable transport
The package SHALL export an `ApnService` class that validates its
config, builds an `ApnTransport` (default:
`createMemoryApnTransport`), and exposes
`initialize` / `sendNotification` / `sendBulkNotifications` /
`shutdown` / `isInitialized`. Per-device failures SHALL appear inside
the returned `PushNotificationResult.errors` array; the service SHALL
NOT throw for per-device delivery failures. Transport-init errors
SHALL fold into `APN_TRANSPORT_INIT_FAILED`; transport-shutdown
errors fold into `APN_TRANSPORT_SHUTDOWN_FAILED`. The
`MemoryApnTransport` ships as the deterministic default; hosts inject
their own transport for production `@parse/node-apn` delivery.
**Verified by:** `src/services/apn-service/apn-service.test.ts`,
`src/transport/apn-transport/apn-transport.test.ts`.
#### Scenario: missing auth material raises APN_CONFIG_MISSING
- **WHEN** `initialize()` runs with no `cert`, `key`, `pfx`, or `token`
- **THEN** the service throws `NotificationsPluginError(APN_CONFIG_MISSING)`
#### Scenario: incomplete token raises APN_CONFIG_INVALID
- **WHEN** `initialize()` runs with `token: { key, keyId: "", teamId }`
- **THEN** the service throws `NotificationsPluginError(APN_CONFIG_INVALID)`
#### Scenario: per-device failure surfaces in the result
- **WHEN** `sendNotification` is called against a transport whose `failurePlan` maps `bad` → `{ status: 410, reason: "Unregistered" }`
- **THEN** the call resolves with `{ sent: 0, failed: 1, errors: [{ status: 410, reason: "Unregistered" }] }` — no throw
#### Scenario: transport-thrown error folds into a synthetic failure
- **WHEN** `sendNotification` is called against a transport whose `send` throws
- **THEN** the call resolves with `{ sent: 0, failed: 1, errors: [{ error: <message> }] }` — no throw
#### Scenario: bulk send empty list returns 0/0
- **WHEN** `sendBulkNotifications([], payload)` is invoked
- **THEN** the call resolves with `{ sent: 0, failed: 0 }` without contacting the transport
### Requirement: Hono OpenAPI route groups
The package SHALL export `createNotificationsRoutes(config)` and
`createPushRoutes(config)` that return `OpenAPIHono` route bundles.
Notifications routes SHALL cover 11 endpoints (list, unread-count,
create, get, patch, mark-read, mark-many, mark-all, dismiss, delete,
grouped-by-type). Push routes SHALL cover 5 endpoints (register
token, unregister token, list tokens, send single, send bulk).
Every route SHALL require auth via `c.get("user")` and return
`401 { error, code: NOTIFICATIONS_PLUGIN.UNAUTHENTICATED }` when
missing. Mutating routes SHALL verify the notification belongs to
the authenticated user; mismatches return `403 { code: FORBIDDEN }`.
Delivery endpoints SHALL return `503` when `apnService === null`
(i.e. `enablePush === false`). Every successful mutation SHALL
forward a `NotificationsRouteEvent` to the host's `ctx.eventBus`
through the injected `emitEvent` callback.
**Verified by:** `src/routes/notifications-routes/notifications-routes.test.ts`,
`src/routes/push-routes/push-routes.test.ts`.
#### Scenario: missing user returns 401
- **WHEN** any route is hit without `c.get("user")` set
- **THEN** the response is `401` with body `{ error: "Authentication required", code: "NOTIFICATIONS_PLUGIN.UNAUTHENTICATED" }`
#### Scenario: ownership mismatch returns 403
- **WHEN** `PATCH /notifications/{id}` is invoked by a user whose id does not match the notification's `recipient`
- **THEN** the response is `403` with `code: NOTIFICATIONS_PLUGIN.FORBIDDEN`
#### Scenario: delivery without APN returns 503
- **WHEN** `POST /push/send` is invoked with `apnService === null` in the route config
- **THEN** the response is `503` with `code: NOTIFICATIONS_PLUGIN.APN_SERVICE_UNAVAILABLE`
#### Scenario: successful create forwards event-bus event
- **WHEN** `POST /notifications` succeeds
- **THEN** the injected `emitEvent` callback receives `{ type: "notification:created", payload: { notificationId, recipientId, type } }`
#### Scenario: duplicate device token returns 409
- **WHEN** `POST /push/tokens` is invoked with a token that triggers `DEVICE_TOKEN_DUPLICATE`
- **THEN** the response is `409` with `code: NOTIFICATIONS_PLUGIN.DEVICE_TOKEN_DUPLICATE`
#### Scenario: bulk send caps recipients at maxBulkRecipients
- **WHEN** `POST /push/send-bulk` is invoked with `userIds` longer than `maxBulkRecipients`
- **THEN** the route processes only the first `maxBulkRecipients` entries; `totalUsers` reflects the cap
### Requirement: Error-code registry
The package SHALL export `NOTIFICATIONS_PLUGIN_CODES` (frozen
record built by `defineCodes("NOTIFICATIONS_PLUGIN", [...])`) and a
`NotificationsPluginError` class extending `SuaveplanError`. Every
code SHALL be reachable from at least one production source file;
the spec-conformance test SHALL grep the source tree to assert
reachability.
**Verified by:** `src/error-codes/error-codes.test.ts`.
#### Scenario: namespace literal is NOTIFICATIONS_PLUGIN
- **WHEN** every value of `NOTIFICATIONS_PLUGIN_CODES` is inspected
- **THEN** each value starts with `"NOTIFICATIONS_PLUGIN."`
#### Scenario: every code reachable from src/
- **WHEN** the spec test concatenates every non-test `.ts` under `src/`
- **THEN** the concatenation contains `NOTIFICATIONS_PLUGIN_CODES.<KEY>` for every key
### Requirement: OpenTelemetry instruments
The package SHALL expose `getNotificationsInstruments()` /
`resetNotificationsInstruments()` returning six counters
(`notificationsCreatedTotal`, `notificationsReadTotal`,
`notificationsDeletedTotal`, `pushSentTotal`, `pushFailedTotal`,
`deviceTokensRegisteredTotal`). Each counter SHALL be emitted from
at least one production code path and SHALL be observable through
an `InMemoryMetricExporter` in the test suite.
**Verified by:** `src/otel/instruments.test.ts`.
#### Scenario: bundle is memoised
- **WHEN** `getNotificationsInstruments()` is called twice without a reset
- **THEN** both calls return the same object reference
#### Scenario: counters emit on flush
- **WHEN** counters are incremented and `flushTestMetrics()` is called
- **THEN** the exporter snapshot contains every counter name
### Requirement: Service-registry augmentation
The package SHALL augment
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
interface with three slots: `notificationRepository`
(`INotificationRepository`), `deviceTokenRepository`
(`IDeviceTokenRepository`), and `apnService` (`ApnService`). The
package barrel SHALL side-effect import the augmentation so
consumers that import any public symbol pick up the type widening
automatically.
**Verified by:** `src/api-plugin-notifications.spec-conformance.test.ts`.
#### Scenario: registry slots typed
- **WHEN** a downstream consumer calls `ctx.getService("notificationRepository")` against the augmented registry
- **THEN** the result type narrows to `INotificationRepository | undefined` without a cast
### Requirement: NotificationDispatcher orchestrator
The package SHALL export a `NotificationDispatcher` class that
resolves `@suaveplan/notification-channel`'s `DeliveryChannel`
instances by the dispatcher-facing channel name and exposes a
`dispatch(input: DispatchInput): Promise<DispatchResult>` surface.
The dispatcher SHALL fan a single input out to one of the registered
delivery channels (defaulting to the configured `defaultChannel`),
record an OTel counter increment per dispatch
(`notificationsDispatchedTotal` on success,
`notificationsDispatchFailedTotal` on failure), and forward a
`delivery:sent` or `delivery:bounced` event through the configured
`DeliveryEventStore` (when one is supplied). Channel selection
failures SHALL surface as
`NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`. Underlying
transport failures returned by the channel as `success === false`
SHALL surface as `NotificationsPluginError(DISPATCH_FAILED)` carrying
the channel error as `cause`. Per-dispatch idempotency SHALL be
applied through an injected `DispatcherIdempotencyAdapter` whose
default implementation (`NoopIdempotencyAdapter`) is the identity.
**Verified by:** `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to the default channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to an explicit channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_CHANNEL_UNAVAILABLE for an unknown channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_FAILED when the channel returns failed"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:sent through the configured store"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:bounced when the channel fails"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchedTotal on success"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchFailedTotal on failure"`.
#### Scenario: dispatch routes to the default channel when channel is unspecified
- **WHEN** `dispatcher.dispatch({ recipient, body })` is called and `defaultChannel === "apn"`
- **THEN** the registered APN `DeliveryChannel` receives `send(notification)` and the result `channel === "apn"`
#### Scenario: dispatch routes to an explicit channel when provided
- **WHEN** `dispatcher.dispatch({ recipient, body, channel: "email" })` is called against a dispatcher with both `apn` and `email` channels registered
- **THEN** only the email channel receives `send`, and the result `channel === "email"`
#### Scenario: unknown channel raises DISPATCH_CHANNEL_UNAVAILABLE
- **WHEN** `dispatcher.dispatch({ channel: "sms", … })` is called and no `sms` channel is registered
- **THEN** the call rejects with `NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`
#### Scenario: channel-returned failure raises DISPATCH_FAILED
- **WHEN** the underlying channel's `send` returns `{ success: false, error: "transport failed" }`
- **THEN** `dispatch` rejects with `NotificationsPluginError(DISPATCH_FAILED)` whose `context.channelError === "transport failed"`
#### Scenario: delivery-tracking sees a delivery:sent event on success
- **WHEN** `dispatch` succeeds and a `DeliveryEventStore` is configured
- **THEN** the store records a `DeliveryEvent` with `eventType === "sent"`, the dispatcher's tenantId, and `messageId === dispatchResult.id`
#### Scenario: delivery-tracking sees a delivery:bounced event on failure
- **WHEN** `dispatch` fails through a channel failure and a `DeliveryEventStore` is configured
- **THEN** the store records a `DeliveryEvent` with `eventType === "bounced"`
#### Scenario: idempotency adapter dedupes repeated dispatches
- **WHEN** the dispatcher is configured with an adapter that caches by key and `dispatch` is invoked twice with the same key
- **THEN** the underlying channel's `send` is called only once
### Requirement: ApnDeliveryChannel adapter
The package SHALL export an `ApnDeliveryChannel` class implementing
`@suaveplan/notification-channel`'s `DeliveryChannel` interface
against the in-package `ApnService`. The channel SHALL be registered
under `name === "apn"` and SHALL accept the device token via
`Notification.metadata.deviceToken` (string) or
`Notification.metadata.deviceTokens` (string array, for fan-out).
Missing-token inputs SHALL return
`{ success: false, error: "deviceToken metadata required" }`. APN
per-device failures SHALL fold into a single `DeliveryResult` whose
`success` reflects whether at least one delivery succeeded.
**Verified by:** `src/channels/apn-channel/apn-channel.test.ts::"sends through ApnService for a single deviceToken"`, `src/channels/apn-channel/apn-channel.test.ts::"fans out to multiple deviceTokens via metadata.deviceTokens"`, `src/channels/apn-channel/apn-channel.test.ts::"returns success false when deviceToken metadata is missing"`, `src/channels/apn-channel/apn-channel.test.ts::"folds APN per-device failures into the DeliveryResult"`, `src/channels/apn-channel/apn-channel.test.ts::"reports canSend true only for push channel notifications"`.
#### Scenario: single deviceToken in metadata
- **WHEN** `apnChannel.send({ channel: "push", metadata: { deviceToken: "tok" }, body: "hi", … })` is called
- **THEN** `ApnService.sendNotification` is invoked with `payload.deviceToken === "tok"` and the returned `DeliveryResult.success === true`
#### Scenario: deviceTokens array fans out
- **WHEN** `apnChannel.send({ channel: "push", metadata: { deviceTokens: ["a","b"] }, body: "hi", … })` is called
- **THEN** `ApnService.sendBulkNotifications` is invoked with the two-element array and the returned `DeliveryResult.success === true`
#### Scenario: missing deviceToken metadata
- **WHEN** `apnChannel.send` is called with no `deviceToken` and no `deviceTokens`
- **THEN** the call resolves with `{ success: false, channel: "apn", error: "deviceToken metadata required" }`
#### Scenario: APN per-device failure surfaces as DeliveryResult.error
- **WHEN** the underlying `ApnService.sendNotification` returns `{ sent: 0, failed: 1, errors: [{ error: "Unregistered" }] }`
- **THEN** `apnChannel.send` resolves with `success: false` and `error: "Unregistered"`
#### Scenario: canSend gates on channel
- **WHEN** `apnChannel.canSend(notification)` is called
- **THEN** the result is `true` iff `notification.channel === "push"`
### Requirement: EmailDeliveryChannel adapter
The package SHALL export an `EmailDeliveryChannel` class implementing
`@suaveplan/notification-channel`'s `DeliveryChannel` interface
against `@suaveplan/emailer`'s `EmailService`. The channel SHALL be
registered under `name === "email"` and SHALL map
`Notification.recipient` → `EmailMessage.to`, `Notification.subject`
→ `EmailMessage.subject` (defaulting to the configured
`defaultSubject` when missing), `Notification.body` →
`EmailMessage.text` (or `html` when `metadata.html === true`).
Transport failures returned by `EmailService.send` SHALL surface as
`{ success: false, error: <error message> }`. The channel SHALL throw
`NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)` at construction
when `emailService` is `null`.
**Verified by:** `src/channels/email-channel/email-channel.test.ts::"sends through EmailService with subject + text"`, `src/channels/email-channel/email-channel.test.ts::"falls back to defaultSubject when notification has no subject"`, `src/channels/email-channel/email-channel.test.ts::"sends HTML body when metadata.html is true"`, `src/channels/email-channel/email-channel.test.ts::"folds transport failure into DeliveryResult.error"`, `src/channels/email-channel/email-channel.test.ts::"throws EMAIL_SERVICE_UNAVAILABLE when constructed with null service"`, `src/channels/email-channel/email-channel.test.ts::"canSend gates on channel === email"`.
#### Scenario: success path with explicit subject
- **WHEN** `emailChannel.send({ channel: "email", recipient: "x@y.z", subject: "S", body: "B", … })` is called
- **THEN** `EmailService.send` receives `{ to: "x@y.z", subject: "S", text: "B" }` and the channel returns `{ success: true, channel: "email" }`
#### Scenario: default subject fallback
- **WHEN** the notification has no `subject` and the channel was constructed with `defaultSubject: "Notification"`
- **THEN** `EmailService.send` receives `subject: "Notification"`
#### Scenario: HTML body via metadata
- **WHEN** `emailChannel.send({ channel: "email", metadata: { html: true }, body: "<p>x</p>", … })` is called
- **THEN** `EmailService.send` receives `{ html: "<p>x</p>" }` instead of `text`
#### Scenario: transport failure
- **WHEN** the underlying `EmailService.send` returns `{ success: false, error: new Error("smtp dead"), … }`
- **THEN** the channel returns `{ success: false, error: "smtp dead" }`
#### Scenario: null service refused at construction
- **WHEN** `new EmailDeliveryChannel({ emailService: null, … })` is invoked
- **THEN** the constructor throws `NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)`
#### Scenario: canSend gating
- **WHEN** `emailChannel.canSend({ channel: "email", … })` versus `{ channel: "push", … }`
- **THEN** the first returns `true`, the second returns `false`
### Requirement: DispatcherIdempotencyAdapter contract
The package SHALL export a `DispatcherIdempotencyAdapter` interface
exposing exactly one method
`execute<T>(key: string, fn: () => Promise<T>): Promise<T>` and a
default `NoopIdempotencyAdapter` class that simply calls `fn()`.
Callers SHALL be able to plug `@suaveplan/idempotency`'s
`IdempotencyGuard` (whose `execute(key, fn, opts?)` signature is
structurally compatible) without the package importing the guard at
runtime. The dispatcher SHALL call `adapter.execute(key, () =>
underlyingDispatch())` when a `key` is derivable from the input,
short-circuiting duplicate dispatches with the adapter's cached
return value.
**Verified by:** `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"NoopIdempotencyAdapter calls fn exactly once"`, `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"a caching adapter dedupes repeated executions"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"idempotency adapter dedupes repeated dispatches"`.
#### Scenario: noop adapter is the identity
- **WHEN** `new NoopIdempotencyAdapter().execute("k", fn)` is invoked
- **THEN** `fn()` is called exactly once and its result is returned
#### Scenario: caching adapter dedupes
- **WHEN** the same `key` is passed twice and the adapter caches by key
- **THEN** the underlying `fn` is invoked only once and both calls return the same result
### Requirement: Dispatch input/result schemas
The package SHALL export `DispatchInputSchema` and
`DispatchResultSchema` (Zod) plus matching `DispatchInput` /
`DispatchResult` types. `DispatchInputSchema` SHALL accept
`recipient` (non-empty string), `body` (non-empty string), optional
`channel` (one of the `notification-channel` channel literals),
optional `subject`, optional `metadata` (record),
optional `priority`, and an optional `idempotencyKey`. Invalid input
SHALL produce `NotificationsPluginError(DISPATCH_INPUT_INVALID)`
carrying the `ZodError` as `cause`.
**Verified by:** `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty recipient"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty body"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts a minimal payload"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts idempotencyKey"`, `src/schemas/dispatch-schemas.test.ts::"DispatchResultSchema accepts canonical shape"`.
#### Scenario: empty recipient rejected
- **WHEN** `DispatchInputSchema.safeParse({ recipient: "", body: "x" })` is invoked
- **THEN** the result `success === false`
#### Scenario: minimal payload accepted
- **WHEN** `DispatchInputSchema.safeParse({ recipient: "u", body: "b" })` is invoked
- **THEN** the result `success === true`
### Requirement: Dispatcher error codes wired into production paths
The registry SHALL include `DISPATCH_FAILED`,
`DISPATCH_CHANNEL_UNAVAILABLE`, `DISPATCH_INPUT_INVALID`, and
`EMAIL_SERVICE_UNAVAILABLE` codes, and every one SHALL be thrown
from at least one production source under `src/`. The
`error-codes.test.ts` reachability scan SHALL include all four codes.
**Verified by:** `src/error-codes/error-codes.test.ts::"every NOTIFICATIONS_PLUGIN_CODES value is referenced by src/"`.
#### Scenario: registry exposes the four dispatcher codes
- **WHEN** `Object.values(NOTIFICATIONS_PLUGIN_CODES)` is inspected
- **THEN** the array contains `NOTIFICATIONS_PLUGIN.DISPATCH_FAILED`, `NOTIFICATIONS_PLUGIN.DISPATCH_CHANNEL_UNAVAILABLE`, `NOTIFICATIONS_PLUGIN.DISPATCH_INPUT_INVALID`, and `NOTIFICATIONS_PLUGIN.EMAIL_SERVICE_UNAVAILABLE`
#### Scenario: every dispatcher code reachable from src
- **WHEN** the spec-conformance scan concatenates every non-test `.ts` under `src/`
- **THEN** the concatenation contains `NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED`, `…DISPATCH_CHANNEL_UNAVAILABLE`, `…DISPATCH_INPUT_INVALID`, and `…EMAIL_SERVICE_UNAVAILABLE`
### Requirement: Dispatcher OTel counters
`getNotificationsInstruments()` SHALL expose two additional counters
beyond the original six: `notificationsDispatchedTotal` (incremented
by `NotificationDispatcher` on a successful dispatch, with `channel`
attribute) and `notificationsDispatchFailedTotal` (incremented on a
failed dispatch, with `channel` + `reason` attributes). Both SHALL
be observable through an `InMemoryMetricExporter` in the OTel test.
**Verified by:** `src/otel/instruments.test.ts::"exposes dispatcher counters"`, `src/otel/instruments.test.ts::"dispatcher counters emit on flush"`.
#### Scenario: instrument bundle exposes the two new counters
- **WHEN** `getNotificationsInstruments()` is called
- **THEN** the returned object has `notificationsDispatchedTotal` and `notificationsDispatchFailedTotal` properties
#### Scenario: counters appear in the exporter snapshot
- **WHEN** both counters are incremented and `flushTestMetrics()` is called
- **THEN** the exporter snapshot includes both metric names
### Requirement: Dispatcher service-registry slots
The package SHALL extend the existing `ServiceRegistry` module
augmentation with two optional slots:
`notificationDispatcher` (`NotificationDispatcher`) and
`emailService` (`EmailService` from `@suaveplan/emailer`). The slots
SHALL only be populated when the plugin's `enableDispatcher` option
is `true` (dispatcher) or when the host injects an `EmailService`
(email).
**Verified by:** `src/api-plugin-notifications.spec-conformance.test.ts::"registry slots include dispatcher and emailService"`, `src/plugin/plugin.test.ts::"onInit registers notificationDispatcher when enableDispatcher is on"`.
#### Scenario: dispatcher slot populated when enabled
- **WHEN** `onInit` runs with `enableDispatcher: true`
- **THEN** `ctx.getService("notificationDispatcher")` resolves to a `NotificationDispatcher` instance
#### Scenario: emailService slot populated when injected
- **WHEN** the host calls `createNotificationsPlugin({}, { emailService })` and `onInit` runs
- **THEN** `ctx.getService("emailService")` resolves to the same `EmailService` instance

View File

@@ -0,0 +1,157 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-payment (add-api-plugin-payment)
**Package**: `@suaveplan/api-plugin-payment`
**Status**: ACCEPTED
## ADDED Requirements
### Requirement: createPaymentPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createPaymentPlugin(options): PluginDescriptor<PaymentPluginConfig>` that validates `options` against `PaymentPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `PaymentPluginError(INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "payment"`, the resolved `configSchema`, and the 4-phase lifecycle hooks.
#### Scenario: factory produces a valid descriptor
- **WHEN** `createPaymentPlugin({ provider, webhookSecret: "s" })` is called with a real `PaymentProvider` and a non-empty webhook secret
- **THEN** the returned descriptor satisfies `PluginDescriptor<PaymentPluginConfig>` with `name === "payment"` and at least `onInit` defined
#### Scenario: invalid options raise PAYMENT_PLUGIN.INVALID_INPUT
- **WHEN** `createPaymentPlugin({} as never)` is called (missing `provider` + `webhookSecret`)
- **THEN** `PaymentPluginError(INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createPaymentPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise PAYMENT_PLUGIN.INVALID_INPUT"`
---
### Requirement: 6-step onInit lifecycle
The descriptor's `onInit(ctx)` hook SHALL execute the canonical 6-step flow:
1. Resolve `app` from `ServiceRegistry`; missing `app` SHALL throw `PaymentPluginError(SERVICE_NOT_AVAILABLE)` carrying `context.missing = ["app"]`.
2. Resolve the configured `PaymentProvider` from the caller-supplied `options.provider` (no default — caller injects).
3. Construct the `PaymentService` wrapping `provider` + the `PaymentIntentStore` (default `MemoryPaymentIntentStore`).
4. Register `paymentService` in the `ServiceRegistry` (typed slot via module augmentation).
5. Register the 6 owner-scoped OpenAPI routes via `definePluginRoutes` at `routePrefix` (default `/payments`). Mount the webhook receiver (`POST /payments/webhook`) as a separate sub-router that reads the raw body before Zod parsing.
6. Log readiness via `ctx.logger.info`.
#### Scenario: missing app raises SERVICE_NOT_AVAILABLE
- **WHEN** `onInit` runs without `app` registered in the host `ServiceRegistry`
- **THEN** `PaymentPluginError(SERVICE_NOT_AVAILABLE)` is thrown with `context.missing` including `"app"`
#### Scenario: routePrefix collision raises ROUTE_PREFIX_COLLISION
- **WHEN** another plugin has already registered routes under the same prefix
- **THEN** `PaymentPluginError(ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix` set to the offending value
**Verified by:** `src/plugin/plugin.test.ts::"onInit executes the 6-step lifecycle"` AND `src/plugin/plugin.test.ts::"missing app raises SERVICE_NOT_AVAILABLE"` AND `src/plugin/plugin.test.ts::"routePrefix collision raises ROUTE_PREFIX_COLLISION"`
---
### Requirement: 6 owner-scoped routes + 1 webhook receiver, all Zod-typed
The package SHALL export `buildPaymentRoutes({service, idempotencyMiddleware})` returning route entries for:
- `POST /intents` (idempotent), `GET /intents`, `GET /intents/{id}`,
- `POST /intents/{id}/confirm`, `POST /intents/{id}/capture`, `POST /intents/{id}/cancel`,
- `POST /intents/{id}/refund` (idempotent).
Each route SHALL be `requireAuth()`-gated and SHALL scope operations to `c.get("user").id`. Foreign-intent access SHALL return HTTP 404 (anti-enumeration: a request from user A for an intent owned by user B is indistinguishable from a missing intent).
The webhook receiver `POST /webhook` SHALL be mounted as a separate sub-router that reads the raw request body, verifies the provider HMAC (`X-Signature` + `X-Timestamp`) against `options.webhookSecret` via `WebhookVerifier` from `@suaveplan/payment`, and ONLY after verification deserializes the body and dispatches.
#### Scenario: owner-scoped GET on a foreign intent returns 404
- **WHEN** user A creates an intent and user B fetches `GET /intents/{idOfA}` with B's valid auth cookie
- **THEN** the response is HTTP 404 (the body envelope reports `PAYMENT_PLUGIN.NOT_FOUND`)
#### Scenario: webhook with invalid HMAC is rejected before deserialisation
- **WHEN** `POST /webhook` is invoked with a tampered signature
- **THEN** the response is HTTP 400 carrying `WEBHOOK_SIGNATURE_INVALID` AND `payments.webhook_signature_failures_total` is incremented AND the body is NOT JSON-parsed
#### Scenario: webhook with valid HMAC is accepted
- **WHEN** `POST /webhook` is invoked with a signature computed from the configured secret over `timestamp.body`
- **THEN** the response is HTTP 200 AND `payments.webhook_received_total{outcome:"accepted"}` is incremented
**Verified by:** `src/routes/build-routes.test.ts::"every route is requireAuth-gated"` AND `tests/integration/full-lifecycle.test.ts::"foreign intent returns 404 anti-enumeration"` AND `tests/integration/webhook.test.ts::"valid HMAC accepted"` AND `tests/integration/webhook.test.ts::"invalid HMAC rejected before deserialisation"`
---
### Requirement: PaymentService registered in ServiceRegistry with owner-scoped lifecycle
`onInit` SHALL call `ctx.registerService("paymentService", createPaymentService({provider, store, clock}))`. The service SHALL expose `createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`, `listIntents`, each accepting an `ownerId` and rejecting access to non-owned intents with `PaymentPluginError(NOT_FOUND)` (statusCode 404). The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with `paymentService: PaymentService`.
#### Scenario: downstream plugin reads paymentService with type safety
- **WHEN** a sibling plugin's `onInit(ctx)` calls `ctx.getService("paymentService")`
- **THEN** the return type is `PaymentService | undefined` (no cast) AND the runtime value exposes all 7 lifecycle methods
#### Scenario: getIntent for foreign owner returns NOT_FOUND
- **WHEN** `service.getIntent({ id: "abc", ownerId: "userB" })` is called for an intent owned by `userA`
- **THEN** `PaymentPluginError(NOT_FOUND)` is thrown with `statusCode === 404`
**Verified by:** `src/services/payment-service.test.ts::"getIntent for foreign owner returns NOT_FOUND"` AND `src/api-plugin-payment.spec-conformance.test.ts::"ServiceRegistry slot typed as PaymentService"`
---
### Requirement: Idempotency-Key middleware on POST /intents and POST /refund
The package SHALL export `createPaymentIdempotencyMiddleware({store})` and apply it to `POST /intents` and `POST /intents/{id}/refund`. When the request carries an `Idempotency-Key` header, the middleware SHALL replay the previously-recorded response body for the same `(ownerId, key)` pair instead of invoking the provider a second time. When the same key is used with a DIFFERENT request body, the middleware SHALL raise `PaymentPluginError(IDEMPOTENCY_CONFLICT)` (statusCode 409).
#### Scenario: same Idempotency-Key returns the same intent
- **WHEN** `POST /intents` is called twice with identical body and identical `Idempotency-Key`
- **THEN** the second call returns the same `intent.id` AND the provider was invoked exactly once
#### Scenario: different body under same key raises IDEMPOTENCY_CONFLICT
- **WHEN** `POST /intents` is called with the same `Idempotency-Key` but a different amount
- **THEN** the response is HTTP 409 carrying `PAYMENT_PLUGIN.IDEMPOTENCY_CONFLICT`
**Verified by:** `src/middleware/idempotency-middleware.test.ts::"same key + body returns cached response"` AND `tests/integration/idempotency.test.ts::"second create with same key returns same intent"` AND `tests/integration/idempotency.test.ts::"different body under same key raises 409"`
---
### Requirement: 15 PAYMENT_PLUGIN_* codes; every code thrown from a production path
`PAYMENT_PLUGIN_CODES` SHALL define exactly 15 codes: `INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `PROVIDER_ERROR`, `WEBHOOK_SIGNATURE_INVALID`, `WEBHOOK_TIMESTAMP_INVALID`, `IDEMPOTENCY_CONFLICT`, `INVALID_STATE`, `AMOUNT_MISMATCH`, `CURRENCY_MISMATCH`, `REFUND_EXCEEDS_CAPTURED`, `PROVIDER_NOT_CONFIGURED`, `INTENT_CREATION_FAILED`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`. Every code SHALL be raised by ≥1 production call site.
**Verified by:** `src/api-plugin-payment.spec-conformance.test.ts::"every PAYMENT_PLUGIN_* code is thrown from a production path"`
---
### Requirement: 5 OTel counters + 1 histogram emit per operation
The package SHALL register counters `payments.intents_created_total{outcome,provider}`, `payments.captures_total{outcome,provider}`, `payments.refunds_total{outcome,provider}`, `payments.webhook_received_total{outcome,provider}`, `payments.webhook_signature_failures_total{provider}`, AND histogram `payments.intent_duration_ms{provider}`. Every route SHALL emit the matching counter regardless of success/failure; intent-creation SHALL also record the histogram.
#### Scenario: createIntent emits counter + histogram
- **WHEN** `POST /intents` succeeds against the in-memory MeterProvider
- **THEN** `payments.intents_created_total{outcome:"success"}` records one observation AND `payments.intent_duration_ms` records one observation
#### Scenario: webhook signature failure increments dedicated counter
- **WHEN** `POST /webhook` is called with an invalid signature
- **THEN** `payments.webhook_signature_failures_total` records one observation AND `payments.webhook_received_total{outcome:"rejected"}` records one observation
**Verified by:** `src/otel/instruments.test.ts::"counter + histogram emit per intent create"` AND `tests/integration/webhook.test.ts::"invalid HMAC rejected before deserialisation"`
---
### Requirement: Server-tier package shape, full 3-way plug-and-play
The package SHALL run in Bun + Node. `package.json` SHALL declare 14 subpath exports per feature module (covering `./plugin/plugin`, `./middleware/idempotency-middleware`, `./services/payment-service`, `./store/payment-intent-store`, `./store/memory-store`, `./webhook/verify-webhook`, `./schemas/money`, `./schemas/options`, `./schemas/routes`, `./routes/build-routes`, `./otel/instruments`, `./error-codes/error-codes`, `./types/types`). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise: (a) plugin mounted alongside `createAuthPlugin` against `OpenAPIHono`, (b) a `FakePaymentProvider` implementing the real `PaymentProvider` interface, (c) the full lifecycle: create → confirm → capture → refund → get, (d) cross-user isolation, (e) Idempotency-Key replay, (f) webhook HMAC accept + reject.
#### Scenario: full integration boot
- **WHEN** `tests/integration/full-lifecycle.test.ts` constructs a real `OpenAPIHono`, registers `createAuthPlugin` + `createPaymentPlugin({provider: new FakePaymentProvider()})`, drives the full lifecycle as authenticated user A, then attempts foreign-intent read as user B
- **THEN** every step succeeds, the final `GET /intents/{id}` for user A shows `status:"captured"` then `partially_refunded` after a partial refund, AND user B's `GET /intents/{idOfA}` returns 404
**Verified by:** `tests/integration/full-lifecycle.test.ts::"full lifecycle: create → confirm → capture → refund → get"` AND `tests/integration/full-lifecycle.test.ts::"foreign intent returns 404 anti-enumeration"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,210 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-rate-limit (add-api-plugin-rate-limit)
**Package**: `@suaveplan/api-plugin-rate-limit`
**Status**: ACCEPTED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: `createRateLimitPlugin` returns a typed `PluginDescriptor`
The package SHALL export `createRateLimitPlugin(options): PluginDescriptor<RateLimitPluginConfig>`. The factory SHALL `safeParse` the options against `RateLimitPluginOptionsSchema` and SHALL throw `RateLimitPluginError(INVALID_INPUT)` carrying the underlying `ZodError` as `cause` on failure.
#### Scenario: factory returns a descriptor with name "rate-limit"
- **WHEN** caller passes valid options
- **THEN** the returned descriptor has `name === "rate-limit"`, `version === "0.1.0"`, and `defaults` populated with the resolved config
#### Scenario: invalid options surface as INVALID_INPUT
- **WHEN** caller passes `{ defaultLimit: -1 }` (negative)
- **THEN** the call throws `RateLimitPluginError` with `code === RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT`
**Verified by:** `src/plugin/plugin.test.ts::"returns a descriptor with name rate-limit and the resolved defaults"` AND `src/plugin/plugin.test.ts::"throws INVALID_INPUT when defaultLimit is negative"`
---
### Requirement: canonical 6-step `onInit` lifecycle
The plugin's `onInit` SHALL execute the canonical 6 steps in order: resolve `app` + `clock` + `logger` from the `ServiceRegistry`; build the `RateLimitStorage` (caller-supplied or bundled `MemoryStorage`); construct the `RateLimitService`; register `rateLimitService` in the `ServiceRegistry`; install the global `rateLimit` middleware on `ctx.app`; emit a single info log naming the resolved policy summary. If `app` is absent, `onInit` SHALL throw `RateLimitPluginError(SERVICE_NOT_AVAILABLE)` with `context.missing === ["app"]`.
#### Scenario: missing `app` slot raises SERVICE_NOT_AVAILABLE
- **WHEN** the plugin's `onInit` is invoked with a context whose registry has no `app`
- **THEN** it throws `RateLimitPluginError(SERVICE_NOT_AVAILABLE)`
#### Scenario: full lifecycle installs middleware and registers service
- **WHEN** the plugin's `onInit` runs against a real registry with `app`
- **THEN** `ctx.getService("rateLimitService")` returns the constructed service and the app's middleware stack contains the rate-limit handler
**Verified by:** `src/plugin/plugin.test.ts::"throws SERVICE_NOT_AVAILABLE when the app slot is missing"` AND `src/plugin/plugin.test.ts::"registers rateLimitService and installs the global middleware"`
---
### Requirement: tenant multiplier is APPLIED to the numeric limit
`RateLimitService.check` SHALL multiply the matched rule's `limit` by the resolved tenant's `multiplier` (defaulting to 1) and round to the nearest integer. The decision's `limit` field SHALL reflect the multiplied value, and the underlying `RateLimiterInstance` SHALL be constructed with the multiplied value so the algorithm enforces it.
#### Scenario: multiplier 10 raises a default limit of 5 to 50
- **WHEN** tenant `"enterprise"` has `multiplier: 10` and `defaultLimit` is 5
- **THEN** the first 50 requests for that tenant are allowed and the 51st is denied
**Verified by:** `src/services/rate-limit-service.test.ts::"applies tenant multiplier to the numeric limit"`
---
### Requirement: IP whitelist short-circuits the check
`RateLimitService.check` SHALL return `{ allowed: true, bypassed: true }` without consulting storage when the resolved IP is in `config.ipWhitelist`. The `bypassed_total{reason: "ip-whitelist"}` counter SHALL be incremented.
#### Scenario: whitelisted IP is allowed regardless of policy
- **WHEN** `config.ipWhitelist = ["127.0.0.1"]` and `defaultLimit = 0`
- **THEN** `service.check({ ip: "127.0.0.1", path: "/", method: "GET" })` returns `{ allowed: true, bypassed: true }`
**Verified by:** `src/services/rate-limit-service.test.ts::"short-circuits when the IP is whitelisted"`
---
### Requirement: tenant bypass short-circuits the check
`RateLimitService.check` SHALL return `{ allowed: true, bypassed: true }` without consulting storage when the resolved tenant's `bypass` flag is `true`. The `bypassed_total{reason: "tenant-bypass"}` counter SHALL be incremented.
#### Scenario: bypass tenant always allowed
- **WHEN** `config.tenants = [{ tenantId: "internal", bypass: true }]` and `defaultLimit = 0`
- **THEN** every request from tenant `"internal"` returns `{ allowed: true, bypassed: true }`
**Verified by:** `src/services/rate-limit-service.test.ts::"short-circuits when the tenant has bypass set"`
---
### Requirement: parameterised path matcher
`compilePathPattern` SHALL accept four pattern forms: exact literal, trailing `/*` wildcard, inline `*` segment wildcard, and named `:name` params. `matchPath` SHALL return `{ matched: true, params: { name: value } }` for the named-param form. Unparseable patterns SHALL throw `RateLimitPluginError(INVALID_PATH_PATTERN)`.
#### Scenario: `:id` named-param captures the segment
- **WHEN** `matchPath(compilePathPattern("/api/users/:id"), "/api/users/42")` is called
- **THEN** the result is `{ matched: true, params: { id: "42" } }`
#### Scenario: trailing `/*` matches sub-paths
- **WHEN** `isPathMatch("/api/users/*", "/api/users/42/profile")` is called
- **THEN** it returns `true`
**Verified by:** `src/path-matcher/path-matcher.test.ts::"captures :id named param"` AND `src/path-matcher/path-matcher.test.ts::"matches trailing wildcard sub-paths"`
---
### Requirement: Hono middleware emits 429 + Problem+JSON + Retry-After on denial
The `rateLimit` middleware SHALL return an HTTP 429 response with `Content-Type: application/problem+json` and a body matching `RateLimitProblemBody` on denial. The response SHALL carry the configured `X-RateLimit-*` and/or `RateLimit-*` headers AND, when `emitRetryAfter` is true, a `Retry-After` header carrying the rounded-up seconds until next allowed request.
#### Scenario: denied request gets 429 + problem body + Retry-After
- **WHEN** the service denies the request and `emitRetryAfter` is `true`
- **THEN** the response status is 429, `Content-Type` is `application/problem+json`, the body parses as `RateLimitProblemBody`, and `Retry-After` is present
**Verified by:** `src/middleware/rate-limit.test.ts::"returns 429 + problem body + Retry-After when denied"`
---
### Requirement: rate-limit headers emitted on every response
The `rateLimit` middleware SHALL set the configured header families on the response on BOTH allowed and denied paths. `headerStyle: "x-rate-limit"` emits the legacy triple; `"rfc-9238"` emits the draft RFC triple; `"both"` emits both.
#### Scenario: allowed request gets X-RateLimit-Remaining
- **WHEN** an allowed request flows through the middleware with `headerStyle: "x-rate-limit"`
- **THEN** the response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers
#### Scenario: "both" style emits both families
- **WHEN** an allowed request flows through with `headerStyle: "both"`
- **THEN** the response carries both `X-RateLimit-*` and `RateLimit-*` triples
**Verified by:** `src/middleware/rate-limit.test.ts::"emits X-RateLimit headers on allowed responses"` AND `src/middleware/rate-limit.test.ts::"emits both header families when style is both"`
---
### Requirement: per-tenant isolation
Independent tenants SHALL maintain independent rate-limit counters. Exhausting tenant `A`'s budget MUST NOT affect tenant `B`'s remaining budget.
#### Scenario: tenant A's exhaustion does not affect tenant B
- **WHEN** tenant `A` exhausts a `limit: 3` policy and tenant `B` makes its first request immediately afterwards
- **THEN** tenant `B`'s request is allowed with `remaining: 2`
**Verified by:** `tests/integration/full-app.test.ts::"isolates rate-limit counters per tenant"`
---
### Requirement: storage failure wrapped as STORAGE_FAILURE
`RateLimitService.check` SHALL wrap any rejection from the injected `RateLimitStorage` as `RateLimitPluginError(STORAGE_FAILURE)` carrying the original error as `cause`.
#### Scenario: throwing storage adapter surfaces STORAGE_FAILURE
- **WHEN** the storage adapter's `.get` rejects with a custom error
- **THEN** `service.check(...)` throws `RateLimitPluginError(STORAGE_FAILURE)` with the custom error attached as `cause`
**Verified by:** `src/services/rate-limit-service.test.ts::"wraps storage failure as STORAGE_FAILURE"`
---
### Requirement: OTel instruments emitted on every check
`RateLimitService.check` SHALL increment `rate_limit.checks_total{outcome,routeId,tenantId}` on every code path (allowed / denied / bypassed). Denied checks SHALL also increment `rate_limit.denials_total{routeId,tenantId}`. Bypassed checks SHALL increment `rate_limit.bypassed_total{reason}`. The check duration SHALL be recorded against `rate_limit.check_duration_ms{outcome}`.
#### Scenario: denied request increments denials_total
- **WHEN** a denied check completes
- **THEN** the next exporter flush includes both `checks_total{outcome=denied}` and `denials_total` data points
**Verified by:** `src/services/rate-limit-service.test.ts::"emits OTel counters and histogram"`
---
### Requirement: `rateLimitService` registered in the host ServiceRegistry
After `onInit` completes, `ctx.getService("rateLimitService")` SHALL return the constructed `RateLimitService`. A module augmentation against `@suaveplan/api-plugin/service-registry` SHALL type the slot as `RateLimitService`.
#### Scenario: getService returns the live service
- **WHEN** the plugin's `onInit` has completed
- **THEN** `ctx.getService("rateLimitService")` returns the service instance with the live `config`
**Verified by:** `src/plugin/plugin.test.ts::"registers rateLimitService and installs the global middleware"` AND `src/api-plugin-rate-limit.spec-conformance.test.ts::"ServiceRegistry augmentation types rateLimitService"`
---
### Requirement: every error code reachable from a production path
Every code in `RATE_LIMIT_PLUGIN_CODES` SHALL be thrown by — or carried on `Report.code` of — at least one production code path inside `src/`. The conformance test SHALL pin this contract.
#### Scenario: every code is reachable
- **WHEN** the conformance test enumerates `RATE_LIMIT_PLUGIN_CODES`
- **THEN** it locates a real throw site or surface for each code
**Verified by:** `src/api-plugin-rate-limit.spec-conformance.test.ts::"every code in RATE_LIMIT_PLUGIN_CODES is reachable from src"`
---
### Requirement: `RateLimitDecision` attached to Hono context
The `rateLimit` middleware SHALL call `c.set("rateLimit", decision)` before returning so downstream handlers read the typed decision via `c.get("rateLimit")`. A `ContextVariableMap` module augmentation SHALL declare the slot type.
#### Scenario: downstream handler reads the decision
- **WHEN** the middleware allows a request and `next()` runs
- **THEN** `c.get("rateLimit")` returns the same `RateLimitDecision` the middleware computed
**Verified by:** `src/middleware/rate-limit.test.ts::"attaches the decision to the Hono context"`

View File

@@ -0,0 +1,365 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-search (add-api-plugin-search)
**Package**: `@suaveplan/api-plugin-search`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## Requirements
### Requirement: Plugin factory shape
The package SHALL export `createSearchPlugin(options?: SearchPluginOptions)`
returning a `PluginDescriptor<SearchPluginConfig>` whose `name` is `"search"`,
whose `dependencies` is empty, whose `optionalDependencies` includes
`"database"` and `"events"`, and whose `tags` include `"search"`, `"fulltext"`,
`"facets"`, and `"relevance"`.
#### Scenario: Descriptor metadata
- **WHEN** `createSearchPlugin()` is called with no arguments
- **THEN** the returned descriptor has the exact name, version, dependencies,
optional-dependencies, and tags listed above
#### Scenario: Invalid options reject at the boundary
- **WHEN** `createSearchPlugin({ maxIndexes: 0 })` is called
- **THEN** the factory throws `SearchError` with code
`SEARCH.INVALID_OPTIONS` and the original `ZodError` as `cause`
**Verified by:** `src/plugin/plugin.test.ts::"returns a descriptor with the canonical search-plugin metadata"` AND `src/plugin/plugin.test.ts::"throws SEARCH.INVALID_OPTIONS when options fail schema validation"`
---
### Requirement: Lifecycle (onInit / onReady / onClose)
The descriptor SHALL implement `onInit`, `onReady`, and `onClose`. `onInit`
registers the in-memory `Map<string, SearchIndex>` under the canonical
`searchIndexes` service slot. `onReady` builds the route tree and mounts it
under the configured `routePrefix` (default `/search`). `onClose` clears the
index map.
#### Scenario: onInit registers the index map
- **WHEN** `onInit(ctx)` runs
- **THEN** `ctx.getService("searchIndexes")` returns a non-undefined `Map`
#### Scenario: onReady mounts routes at custom prefix
- **WHEN** the descriptor's `onInit` then `onReady` runs with options
`{ routePrefix: "/api/fts" }`
- **THEN** the host `app.route` was called with `"/api/fts"` as the mount point
#### Scenario: onClose empties the index map
- **WHEN** `onInit` runs, an index is inserted, then `onClose` runs
- **THEN** the index map has size 0
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers the searchIndexes service"` AND `src/plugin/plugin.test.ts::"onReady mounts routes at the configured routePrefix"` AND `src/plugin/plugin.test.ts::"onClose empties the searchIndexes map"`
---
### Requirement: In-memory index lifecycle
The package SHALL expose `createSearchIndex`, `deleteSearchIndex`,
`getSearchIndex`, and `listSearchIndexes`. Creating a duplicate index throws
`SearchError(SEARCH.INDEX_ALREADY_EXISTS)`. Exceeding `maxIndexes` throws
`SearchError(SEARCH.INDEX_LIMIT_REACHED)`. `listSearchIndexes` paginates by
`offset`/`limit` and optionally filters by `status`.
#### Scenario: Duplicate index name
- **WHEN** `createSearchIndex` is called twice with the same name
- **THEN** the second call throws `SearchError` with code
`SEARCH.INDEX_ALREADY_EXISTS`
#### Scenario: Index limit enforcement
- **WHEN** the registered map already contains `maxIndexes` entries and a
new `createSearchIndex` call is made
- **THEN** the call throws `SearchError` with code `SEARCH.INDEX_LIMIT_REACHED`
#### Scenario: List with pagination + status filter
- **WHEN** three indexes exist (one with status `"building"`) and the caller
passes `{ status: "active", limit: 2 }`
- **THEN** the returned `items` array has length ≤ 2 and contains only
active indexes, and `total` reports the unpaged active count
**Verified by:** `src/service/service.test.ts::"createSearchIndex throws SEARCH.INDEX_ALREADY_EXISTS on duplicate name"` AND `src/service/service.test.ts::"createSearchIndex throws SEARCH.INDEX_LIMIT_REACHED when maxIndexes is reached"` AND `src/service/service.test.ts::"listSearchIndexes paginates and filters by status"`
---
### Requirement: Document indexing with limits and field-aware analysis
The package SHALL expose `indexDocument`, `bulkIndexDocuments`, and
`removeDocument`. `indexDocument` runs the index's configured analyzer over
every declared field, stores per-field token lists, and rejects with
`SearchError(SEARCH.INDEX_NOT_FOUND)` when the index is missing. When the
document map would exceed `maxDocumentsPerIndex` AND the doc id is new, it
throws `SearchError(SEARCH.DOCUMENT_LIMIT_REACHED)`. `removeDocument` returns
`false` when the doc id is absent and `true` when it was removed.
#### Scenario: Per-field tokenisation
- **WHEN** an index declares fields `[title, body]` and a document with both
fields is indexed
- **THEN** the stored `IndexedDocument.tokens` map carries one entry per field
with non-empty token arrays
#### Scenario: Document-limit guard
- **WHEN** the index already contains `maxDocumentsPerIndex` documents and a
new document id is indexed
- **THEN** the call throws `SearchError(SEARCH.DOCUMENT_LIMIT_REACHED)`
#### Scenario: Re-indexing an existing doc never exceeds the limit
- **WHEN** the index is at the document limit and `indexDocument` is called
with an id that already exists
- **THEN** the call succeeds and replaces the stored tokens
**Verified by:** `src/service/service.test.ts::"indexDocument analyses every declared field"` AND `src/service/service.test.ts::"indexDocument throws SEARCH.DOCUMENT_LIMIT_REACHED for new ids beyond the cap"` AND `src/service/service.test.ts::"indexDocument replaces tokens for existing ids without triggering the limit"`
---
### Requirement: Search with TF-IDF scoring, filters, sort, facets, highlights
The package SHALL expose `search(query, indexes)` returning a `SearchResult`.
Scoring uses term-frequency × field-weight. Filters accept scalar equality and
`Array.includes` for array values. Sort honours `relevance`, `date_asc`,
`date_desc`, `alpha_asc`, `alpha_desc`. Facets are computed only over fields
declared with `facet: true` and reflect the FILTERED document set. Highlights
wrap matching whole words in the configured `highlightTag`.
#### Scenario: Relevance sort by default
- **WHEN** three documents match a query with different term frequencies
- **THEN** the response `hits` are sorted by descending `score`
#### Scenario: Filter restricts the result set
- **WHEN** a query carries `filters: { tag: "blog" }`
- **THEN** only documents whose `tag` field equals `"blog"` appear in `hits`,
and `total` reflects the filtered count
#### Scenario: Facet honours `facet: true` only
- **WHEN** the index declares `category` with `facet: true` and `body` with
`facet: false`, and the query requests `["category", "body"]`
- **THEN** the response carries exactly one facet block keyed on `category`
#### Scenario: Highlight wraps matching words
- **WHEN** `highlight: true` and `highlightTag: "em"` are set and a document
matches the query token
- **THEN** the response hit's `highlights[fieldName]` contains
`<em>word</em>` strings
#### Scenario: Empty-query token list yields empty result
- **WHEN** the analyzer returns zero tokens for the input query
- **THEN** `search` returns `{ hits: [], total: 0, ... }` and never throws
#### Scenario: Unknown index throws
- **WHEN** the query references an index that does not exist
- **THEN** `search` throws `SearchError` with code `SEARCH.INDEX_NOT_FOUND`
**Verified by:** `src/service/service.test.ts::"search ranks hits by TF × field weight"` AND `src/service/service.test.ts::"search applies scalar and array filters"` AND `src/service/service.test.ts::"search honours sort order"` AND `src/service/service.test.ts::"search emits facets only for fields with facet: true"` AND `src/service/service.test.ts::"search wraps matching words with the configured highlight tag"` AND `src/service/service.test.ts::"search returns empty result when query analyses to zero tokens"` AND `src/service/service.test.ts::"search throws SEARCH.INDEX_NOT_FOUND for an unknown index"`
---
### Requirement: Suggest endpoint (prefix autocomplete)
The package SHALL expose `suggest(query, indexes)` returning ranked prefix
matches over every indexed field. The last analysed token is used as the
prefix, hits are deduplicated by surface form, and results are sorted by
descending occurrence count and truncated to `query.limit`.
#### Scenario: Prefix expansion ranks by frequency
- **WHEN** the index contains documents whose words begin with the query's
last token
- **THEN** `suggest` returns suggestions ordered by descending occurrence
count and truncated to `limit`
#### Scenario: Suggest returns empty for empty token analysis
- **WHEN** the analysed query produces zero tokens
- **THEN** `suggest` returns `{ suggestions: [], query }`
**Verified by:** `src/service/service.test.ts::"suggest ranks prefix matches by occurrence count"` AND `src/service/service.test.ts::"suggest returns empty result when query analyses to zero tokens"`
---
### Requirement: Four built-in analyzers via the registry
The package SHALL register four analyzers — `standard`, `simple`,
`whitespace`, and `keyword` — through `getAnalyzer(name)`. `listAnalyzers()`
returns the four registered names. Unknown analyzer names throw
`SearchError(SEARCH.UNKNOWN_ANALYZER)`.
#### Scenario: Standard analyzer tokenises, removes stop words, stems
- **WHEN** the standard analyzer analyses `"Running the cats quickly"`
- **THEN** the returned token list contains the stemmed forms `run`, `cat`,
`quick` and excludes the stop word `the`
#### Scenario: Keyword analyzer treats input as single token
- **WHEN** the keyword analyzer analyses `"Hello World"`
- **THEN** the returned token list is `["Hello World"]`
#### Scenario: Unknown analyzer name fails fast
- **WHEN** `getAnalyzer("does-not-exist")` is called
- **THEN** the call throws `SearchError` with code `SEARCH.UNKNOWN_ANALYZER`
**Verified by:** `src/analyzer/analyzer.test.ts::"standard analyzer tokenises, removes stop words, and stems"` AND `src/analyzer/analyzer.test.ts::"keyword analyzer returns the trimmed input as a single token"` AND `src/analyzer/analyzer.test.ts::"getAnalyzer throws SEARCH.UNKNOWN_ANALYZER for an unregistered name"`
---
### Requirement: 9 HTTP routes mounted under `routePrefix`
The package SHALL expose `createSearchRoutes(deps)` returning an
`OpenAPIHono` instance with the canonical 9 routes (POST /query,
POST /suggest, GET /indexes, POST /indexes, GET /indexes/:name,
DELETE /indexes/:name, POST /indexes/:name/documents,
POST /indexes/:name/bulk, DELETE /indexes/:name/documents/:docId). Every
request body / param is Zod-validated through `@hono/zod-openapi`. Error
responses are `{ error: string }` and the HTTP status reflects the typed
`SearchError` (`SEARCH.INDEX_NOT_FOUND` → 404, `SEARCH.DOCUMENT_NOT_FOUND` →
404, every other failure → 400).
#### Scenario: POST /query returns hits and emits `search:query:executed`
- **WHEN** a populated index receives a POST /query body
- **THEN** the response status is 200, the body matches `SearchResultSchema`,
and `deps.emitEvent` was invoked with `"search:query:executed"` and a
`SearchQueryExecutedEvent` payload
#### Scenario: 404 for missing index
- **WHEN** POST /query references an unknown index
- **THEN** the response status is 404 with body `{ error: "..." }`
#### Scenario: POST /indexes creates an index
- **WHEN** POST /indexes receives a valid `CreateIndexSchema` body
- **THEN** the response status is 201 with body matching `IndexInfoSchema`
#### Scenario: DELETE /indexes/:name returns 404 for unknown index
- **WHEN** DELETE /indexes/missing is hit on an empty registry
- **THEN** the response status is 404
#### Scenario: DELETE /indexes/:name/documents/:docId removes a doc
- **WHEN** a doc was indexed and then DELETE is invoked on the doc id
- **THEN** the response status is 200 with `{ success: true }` and
`deps.emitEvent` was invoked with `"search:index:updated"` and
`operation: "document_removed"`
**Verified by:** `src/routes/build-routes.test.ts::"POST /query returns 200 and emits search:query:executed"` AND `src/routes/build-routes.test.ts::"POST /query returns 404 when the index does not exist"` AND `src/routes/build-routes.test.ts::"POST /indexes creates an index and returns 201"` AND `src/routes/build-routes.test.ts::"DELETE /indexes/:name returns 404 for an unknown index"` AND `src/routes/build-routes.test.ts::"DELETE /indexes/:name/documents/:docId returns 200 and emits search:index:updated"`
---
### Requirement: Typed error registry
The package SHALL expose `SEARCH_CODES` via `defineCodes("SEARCH", [...])`
covering exactly 10 codes — `INDEX_NOT_FOUND`, `INDEX_ALREADY_EXISTS`,
`INDEX_LIMIT_REACHED`, `DOCUMENT_LIMIT_REACHED`, `DOCUMENT_NOT_FOUND`,
`UNKNOWN_ANALYZER`, `INVALID_OPTIONS`, `INVALID_QUERY`,
`SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION` — and a `SearchError`
class extending `SuaveplanError<SearchErrorCode>` such that
`instanceof SearchError` narrows `.code` to a `SearchErrorCode` literal
union.
#### Scenario: Registry shape is stable
- **WHEN** `Object.values(SEARCH_CODES)` is inspected
- **THEN** the array has exactly 10 unique entries, each beginning with
`"SEARCH."`
#### Scenario: SearchError preserves cause and code narrowing
- **WHEN** `new SearchError(SEARCH_CODES.INDEX_NOT_FOUND, "msg", { cause })`
is constructed
- **THEN** `error instanceof SearchError` is true, `error.code ===
SEARCH_CODES.INDEX_NOT_FOUND`, and `error.cause` is the supplied cause
**Verified by:** `src/error-codes/error-codes.test.ts::"declares 10 SEARCH.* codes"` AND `src/error-codes/error-codes.test.ts::"SearchError narrows code to SearchErrorCode and preserves cause"`
---
### Requirement: OTel instruments on every hot path
The package SHALL emit metrics via a meter named
`@suaveplan/api-plugin-search`. The instrument bundle SHALL include
`search.queries_total{outcome}`, `search.query_duration_ms` (histogram),
`search.documents_indexed_total{op}`, `search.indexes_total{op}`, and
`search.suggest_requests_total{outcome}`. A `resetSearchInstruments()`
helper SHALL allow tests to rebind against a freshly-installed
`MeterProvider`.
#### Scenario: Reset rebinds the meter
- **WHEN** `resetSearchInstruments()` is invoked between two
`getSearchInstruments()` calls under two different `MeterProvider`s
- **THEN** the second call returns a fresh instrument bundle bound to the
second provider
#### Scenario: Successful query bumps queries_total and records duration
- **WHEN** a successful `search()` call completes
- **THEN** the in-memory exporter records a `search.queries_total{outcome="success"}` += 1 and a non-empty
`search.query_duration_ms` histogram observation
**Verified by:** `src/otel/instruments.test.ts::"resetSearchInstruments rebinds against a fresh MeterProvider"` AND `src/service/service.test.ts::"search increments queries_total and records duration"`
---
### Requirement: ServiceRegistry augmentation
The package SHALL augment `@suaveplan/api-plugin/service-registry`'s
`ServiceRegistry` interface with a `searchIndexes` slot typed as
`Map<string, SearchIndex>`. Importing the package barrel SHALL surface the
augmentation as a side effect.
#### Scenario: getService("searchIndexes") types to Map<string, SearchIndex>
- **WHEN** a consumer calls `ctx.getService("searchIndexes")` after importing
`@suaveplan/api-plugin-search`
- **THEN** the inferred return type is `Map<string, SearchIndex> | undefined`
(verified at compile time via `expectTypeOf`)
**Verified by:** `src/api-plugin-search.spec-conformance.test.ts::"augments the ServiceRegistry with the searchIndexes slot"`
---
### Requirement: Typed event-bus emissions
The package SHALL emit nine search events through the host
`EventBusLike` — `search:initialized`, `search:ready`,
`search:index:created`, `search:index:deleted`, `search:index:updated`,
`search:document:indexed`, `search:bulk:completed`,
`search:document:removed`, `search:query:executed` — with strongly typed
payload signatures declared via a module augmentation. The plugin call
sites SHALL NOT use `as any` to bypass the event-map type system.
#### Scenario: Lifecycle events fire on init / ready
- **WHEN** `onInit` then `onReady` run
- **THEN** `eventBus.emit` was called with `"search:initialized"` and
`"search:ready"`
#### Scenario: Query route emits typed `SearchQueryExecutedEvent`
- **WHEN** POST /query handles a successful query
- **THEN** the emitter receives `"search:query:executed"` and a payload
matching `SearchQueryExecutedEvent`
**Verified by:** `src/plugin/plugin.test.ts::"onInit emits search:initialized and onReady emits search:ready"` AND `src/routes/build-routes.test.ts::"POST /query returns 200 and emits search:query:executed"`

View File

@@ -0,0 +1,295 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-site (add-api-plugin-site)
**Package**: `@suaveplan/api-plugin-site`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Stable SITE.* error registry
The package SHALL register exactly 14 stable error codes under the `"SITE"` namespace via `defineCodes("SITE", [...])`, exporting `SITE_CODES` and `SiteError extends SuaveplanError<SiteErrorCode>`. Every code SHALL be reachable from at least one production code path; deletion to satisfy a coverage gate is forbidden.
#### Scenario: registry declares the 14 canonical codes
- **WHEN** a caller imports `SITE_CODES`
- **THEN** the literal values include `INVALID_OPTIONS`, `SERVICE_NOT_AVAILABLE`, `SITE_NOT_FOUND`, `SITE_LIMIT_EXCEEDED`, `DOMAIN_LIMIT_EXCEEDED`, `DOMAIN_ALREADY_CLAIMED`, `LAST_DOMAIN_REMOVAL`, `DOMAIN_NOT_FOUND`, `INVALID_DOMAIN`, `VALIDATION`, `BOOTSTRAP_FAILED`, `STORAGE_ERROR`, `ROUTE_PREFIX_COLLISION`, `RESOLUTION_FAILED` and every value is namespaced `"SITE.<KEY>"`
#### Scenario: SiteError preserves cause + code
- **WHEN** `new SiteError(SITE_CODES.SITE_NOT_FOUND, "missing", { cause })` is constructed
- **THEN** `error.code === "SITE.SITE_NOT_FOUND"`, `error.siteCode === error.code`, `error.cause === cause`
**Verified by:** `src/error-codes/error-codes.test.ts::"declares all 14 canonical SITE.* codes"` AND `src/error-codes/error-codes.test.ts::"SiteError preserves code, message, cause, and siteCode alias"`
---
### Requirement: Zod-validated plugin-options boundary
The package SHALL expose `SitePluginOptionsSchema` with defaults for every option and SHALL reject non-positive numeric caps and empty `defaultDomainPattern` at Zod parse time. `createSitePlugin` SHALL fail with `SiteError(SITE.INVALID_OPTIONS)` carrying the `ZodError` as `cause` when called with bad input. `DEFAULT_CONFIG` SHALL be frozen.
#### Scenario: defaults applied on empty input
- **WHEN** `SitePluginOptionsSchema.parse({})` is invoked
- **THEN** the result is `{maxSitesPerOrg: 10, maxDomainsPerSite: 5, autoCreateDefaultSite: true, defaultDomainPattern: "{slug}.suaveplan.app", enableDomainResolution: true, enablePushNotifications: true, enableSiteSearch: true, routePrefix: "/api"}`
#### Scenario: invalid options fail with SITE.INVALID_OPTIONS
- **WHEN** `createSitePlugin({ maxSitesPerOrg: -1 } as never)` is invoked
- **THEN** it throws a `SiteError` whose `code === SITE.INVALID_OPTIONS` and whose `cause` is the `ZodError`
**Verified by:** `src/schemas/options.test.ts::"applies all defaults when called with empty input"` AND `src/plugin/plugin.test.ts::"rejects invalid options with SITE.INVALID_OPTIONS"`
---
### Requirement: Site / domain / push Zod schemas
The package SHALL inline the canonical Zod surface for sites, domains, and push configs (`SiteSchema`, `CreateSiteSchema`, `UpdateSiteSchema (.strict)`, `SitePublicInfoSchema`, `SiteIdParamSchema`, `DomainSchema`, `AddDomain*Schema`, `RemoveDomain*Schema`, `DomainResolveParam/ResponseSchema`, `ApplePushConfigSchema`, `FirebasePushConfigSchema`, `PushConfigSchema`, `PushConfigValidationResponseSchema`).
#### Scenario: CreateSiteSchema rejects empty domain list
- **WHEN** `CreateSiteSchema.safeParse({ organization_id, name, domains: [] })`
- **THEN** `result.success === false`
#### Scenario: UpdateSiteSchema rejects unknown keys
- **WHEN** `UpdateSiteSchema.safeParse({ name: "x", sneaky: 1 })`
- **THEN** `result.success === false`
#### Scenario: PushConfigSchema requires at least one provider
- **WHEN** `PushConfigSchema.safeParse({})`
- **THEN** `result.success === false`
**Verified by:** `src/schemas/site.test.ts::"rejects empty domain list"` AND `src/schemas/site.test.ts::"rejects unknown keys via .strict()"` AND `src/schemas/push.test.ts::"requires at least one provider"`
---
### Requirement: Domain canonicalisation parity with database
The package SHALL expose `normalizeDomain(input)` which lower-cases, trims, strips `http(s)://`, strips leading `www.`, and strips a single trailing `/`. The semantics SHALL match the SurrealDB `fn::site::domain::normalize` function so application-tier and database-tier writers agree on the canonical form.
#### Scenario: composed prefix normalisation
- **WHEN** `normalizeDomain("HTTPS://WWW.Acme.Example.Com/")`
- **THEN** result is `"acme.example.com"`
**Verified by:** `src/service/normalize-domain.test.ts::"strips composed prefixes"`
---
### Requirement: DomainResolverService — envelope + observability
`DomainResolverService.resolve(domain)` SHALL return `{success, site?, organization?, error?, code?}`. On hit: `success: true` with `site` + `organization`. On miss: `success: false`, `error: "Domain not found"`, `code: SITE.DOMAIN_NOT_FOUND`. On driver throw: `success: false`, message preserved, `code: SITE.RESOLUTION_FAILED`. Every call SHALL increment `site.domain_resolutions_total{outcome}` and record `site.domain_resolution_duration_ms`. `resolveOrThrow` SHALL throw `SiteError` carrying the same code.
#### Scenario: miss envelope
- **WHEN** the underlying query returns `[]`
- **THEN** `resolve("nope")` returns `{success: false, error: "Domain not found", code: "SITE.DOMAIN_NOT_FOUND"}`
#### Scenario: driver error envelope
- **WHEN** the underlying query throws an `Error`
- **THEN** the returned envelope's `code === "SITE.RESOLUTION_FAILED"` and `error` is the error's message
#### Scenario: resolveOrThrow throws SiteError on miss
- **WHEN** `resolveOrThrow("nope")` runs against an empty result
- **THEN** a `SiteError` with `code === "SITE.DOMAIN_NOT_FOUND"` is thrown
**Verified by:** `src/service/domain-resolver.test.ts::"returns DOMAIN_NOT_FOUND code on empty result"` AND `src/service/domain-resolver.test.ts::"returns RESOLUTION_FAILED code when db throws Error"` AND `src/service/domain-resolver.test.ts::"throws DOMAIN_NOT_FOUND on miss"`
---
### Requirement: SiteService — typed CRUD + cap enforcement + event emission
`SiteService` SHALL expose `create`, `createDefaultSite`, `getById`, `requireById`, `listByOrganization`, `countByOrganization`, `update`, `updateSettings`, `updateState`, `addDomain`, `removeDomain`, `archive`, `getActive`, `search`. `create` SHALL enforce `maxSitesPerOrg` (raises `SITE.SITE_LIMIT_EXCEEDED`) and `maxDomainsPerSite` (raises `SITE.DOMAIN_LIMIT_EXCEEDED`). `addDomain` SHALL persist the new domain (upstream bug fix) and reject duplicates with `SITE.DOMAIN_ALREADY_CLAIMED`. `removeDomain` SHALL refuse to leave the site with zero domains via `SITE.LAST_DOMAIN_REMOVAL`. `updateSettings` SHALL emit `site:settings:updated` natively (no monkey-patching).
#### Scenario: create enforces site cap
- **WHEN** a second `create` runs against an organization already at `maxSitesPerOrg: 1`
- **THEN** a `SiteError(SITE.SITE_LIMIT_EXCEEDED)` is thrown
#### Scenario: addDomain persists new domain
- **WHEN** `addDomain(siteId, "B.Example.Com")` is called against a site with `domains: ["a.example.com"]`
- **THEN** the returned site has `domains: ["a.example.com", "b.example.com"]` AND the underlying row is mutated
#### Scenario: addDomain rejects duplicates
- **WHEN** `addDomain(siteId, "A.Example.Com")` is called against a site already containing `"a.example.com"`
- **THEN** a `SiteError(SITE.DOMAIN_ALREADY_CLAIMED)` is thrown
#### Scenario: removeDomain refuses last
- **WHEN** `removeDomain(siteId, "a.example.com")` is called against a site with one domain
- **THEN** a `SiteError(SITE.LAST_DOMAIN_REMOVAL)` is thrown
#### Scenario: updateSettings emits site:settings:updated
- **WHEN** `updateSettings(siteId, { features: ["beta"] })` succeeds
- **THEN** the injected `EventBusLike` receives a `site:settings:updated` payload with `siteId` and `changedFields: ["features"]`
**Verified by:** `src/service/site.test.ts::"throws SITE_LIMIT_EXCEEDED when over the cap"` AND `src/service/site.test.ts::"addDomain persists the normalized domain"` AND `src/service/site.test.ts::"addDomain throws DOMAIN_ALREADY_CLAIMED on duplicates"` AND `src/service/site.test.ts::"removeDomain refuses LAST_DOMAIN_REMOVAL"` AND `src/service/site.test.ts::"updateSettings merges, persists, and emits site:settings:updated"`
---
### Requirement: Hono middleware factory
`createDomainResolverMiddleware(resolver)` SHALL return a Hono middleware that reads `Host` then `X-Forwarded-Host`, normalises via `normalizeDomain`, and on a hit injects `site` / `organization` / `siteId` / `organizationId` into the request context. On miss or absent host the middleware SHALL fall through silently.
#### Scenario: hit injects four context keys
- **WHEN** a request with `Host: https://www.Example.Com:8080` arrives and the resolver hits
- **THEN** `c.get("siteId")`, `c.get("organizationId")`, `c.get("site")`, and `c.get("organization")` are populated
#### Scenario: empty host falls through
- **WHEN** the middleware is invoked against a context whose `req.header("host")` returns `undefined`
- **THEN** `next()` is called once and no site key is set
**Verified by:** `src/middleware/domain-resolver.test.ts::"injects site + organization on a hit"` AND `src/middleware/domain-resolver.test.ts::"falls through when host header is empty (direct unit call)"`
---
### Requirement: Public + private OpenAPI route trees
`createPublicSiteRoutes` SHALL expose `GET /sites/resolve/{domain}` (returning the resolution envelope) and `GET /sites/{id}/public` (returning the sanitised public projection). `createPrivateSiteRoutes` SHALL expose `POST /sites`, `PATCH /sites/{id}`, `POST /sites/{id}/domains`, `DELETE /sites/{id}/domains`. All bodies SHALL be Zod-validated through `@hono/zod-openapi`.
#### Scenario: domain resolution endpoint returns 200 envelope
- **WHEN** `GET /sites/resolve/example.com` runs and the resolver hits
- **THEN** status is `200` and the body is `{success: true, site, organization}`
#### Scenario: site creation 201
- **WHEN** `POST /sites` runs with a valid body
- **THEN** status is `201` and the body is the created site
#### Scenario: site creation 400 on bad body
- **WHEN** `POST /sites` runs with an empty body
- **THEN** status is `>= 400`
**Verified by:** `src/routes/public-routes.test.ts::"resolves a domain to a site envelope"` AND `src/routes/private-routes.test.ts::"creates a site via POST /sites"` AND `src/routes/private-routes.test.ts::"rejects POST /sites with bad input via Zod 400"`
---
### Requirement: Plugin lifecycle + auto-bootstrap
`createSitePlugin` SHALL produce a `PluginDescriptor` with `name: "site"`, `version: "0.1.0"`, `optionalDependencies: ["database", "auth", "events"]`. `onInit` SHALL resolve `db` from `ServiceRegistry` (raising `SITE.SERVICE_NOT_AVAILABLE` if missing), construct + register `siteService`, `domainResolver`, `domainResolverMiddleware`, optionally subscribe to `organization:created`, and emit `site:initialized`. `onReady` SHALL resolve `app`, detect route-prefix collisions (`SITE.ROUTE_PREFIX_COLLISION`), mount the route trees, and emit `site:ready`. `onClose` SHALL drop the `organization:created` listener.
#### Scenario: missing db raises SERVICE_NOT_AVAILABLE
- **WHEN** `onInit` runs without `db` registered on `ServiceRegistry`
- **THEN** a `SiteError(SITE.SERVICE_NOT_AVAILABLE)` is thrown
#### Scenario: route prefix collision raises ROUTE_PREFIX_COLLISION
- **WHEN** the host `app` already exposes a route under `${routePrefix}/sites/*` and `onReady` runs
- **THEN** a `SiteError(SITE.ROUTE_PREFIX_COLLISION)` is thrown
#### Scenario: organization:created auto-creates default site
- **WHEN** the plugin is constructed with `autoCreateDefaultSite: true` and the event bus emits `organization:created`
- **THEN** the service's underlying store contains a site for that organization id
#### Scenario: createDefaultSite failure emits site:bootstrap:failed
- **WHEN** the organization is already at its site cap and the auto-bootstrap listener fires
- **THEN** the event bus emits `site:bootstrap:failed` with `organizationId`
**Verified by:** `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE when db is missing"` AND `src/plugin/plugin.test.ts::"detects route prefix collisions and throws ROUTE_PREFIX_COLLISION"` AND `src/plugin/plugin.test.ts::"auto-creates a default site on organization:created"` AND `src/plugin/plugin.test.ts::"emits site:bootstrap:failed when createDefaultSite throws"`
---
### Requirement: OTel instrument bundle
The package SHALL expose `getSiteInstruments` returning a lazily-built bundle of five OTel instruments: `sitesCreatedTotal`, `sitesUpdatedTotal`, `domainResolutionsTotal`, `domainResolutionDurationMs`, `bootstrapFailedTotal`. The bundle SHALL be cached at module scope and `resetSiteInstruments` SHALL clear the cache so tests can install a fresh `MeterProvider`.
#### Scenario: instruments cached across calls
- **WHEN** `getSiteInstruments()` is invoked twice in succession without a reset
- **THEN** the second call returns the same object instance as the first
#### Scenario: reset rebinds against a fresh MeterProvider
- **WHEN** a fresh `MeterProvider` is installed and `resetSiteInstruments` is called
- **THEN** the next `getSiteInstruments()` produces a new bundle and `forceFlush` collects metrics from it
**Verified by:** `src/otel/instruments.test.ts::"getSiteInstruments returns a cached bundle"` AND `src/otel/instruments.test.ts::"resetSiteInstruments rebinds against a fresh MeterProvider"`
---
### Requirement: ServiceRegistry augmentation
The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with three slots — `siteService`, `domainResolver`, `domainResolverMiddleware` — plus the cumulative host slots `app: OpenAPIHono` and `db: SurrealAdapter`. Importing the package barrel SHALL be sufficient to surface the augmentation.
#### Scenario: descriptor lists optional database / auth / events dependencies
- **WHEN** `createSitePlugin()` returns a descriptor
- **THEN** `descriptor.optionalDependencies === ["database", "auth", "events"]`
**Verified by:** `src/plugin/plugin.test.ts::"descriptor name is 'site' and version is '0.1.0'"`
---
### Requirement: Public surface re-exports
The package barrel SHALL re-export the canonical public surface: `SITE_CODES`, `SiteError`, `DEFAULT_CONFIG`, `mergeConfig`, `SiteService`, `DomainResolverService`, `normalizeDomain`, `createDomainResolverMiddleware`, `createPublicSiteRoutes`, `createPrivateSiteRoutes`, `createSitePlugin`, `getSiteInstruments`, `resetSiteInstruments`, `validatePushConfig`, `SiteSchema`, `DomainSchema`, the four `SITE_STATE_*` constants, **and** `MIDDLEWARE_RECOMMENDATIONS` plus the `MiddlewareRecommendation` type.
#### Scenario: re-export surface count
- **WHEN** the spec-conformance test imports every documented symbol from the package barrel
- **THEN** every symbol resolves and the import does not throw
**Verified by:** `src/api-plugin-site.spec-conformance.test.ts::"re-exports every documented symbol"`
---
### Requirement: Middleware composition recommendations
The package SHALL expose `MIDDLEWARE_RECOMMENDATIONS` — a readonly `MiddlewareRecommendation[]` constant describing the recommended rate-limit window, rate-limit ceiling, and audit category per documented site route group (`public:resolve`, `public:read`, `private:write`). The recommendations SHALL be pure data; the site plugin SHALL NOT bind the recommended middleware itself — consumers compose them via the host app's `app.use(prefix, middleware)` Hono pattern. `MiddlewareRecommendation` SHALL be re-exported from the package barrel.
#### Scenario: bundle covers all three route groups
- **WHEN** a caller imports `MIDDLEWARE_RECOMMENDATIONS`
- **THEN** the array contains exactly three entries whose `routeGroup` fields are `"public:resolve"`, `"public:read"`, and `"private:write"`
#### Scenario: private:write carries the tightest limit
- **WHEN** a caller picks the entry where `routeGroup === "private:write"`
- **THEN** its `rateLimit.limit` is strictly less than the entry where `routeGroup === "public:read"` AND its `auditCategory` is `"data.mutation"`
**Verified by:** `src/integration/middleware-recommendations.test.ts::"exposes three route groups"` AND `src/integration/middleware-recommendations.test.ts::"private:write is tightest and audited as data.mutation"`
---
### Requirement: Host-app middleware composition pattern is integration-tested
The package SHALL ship an integration test under `src/integration/middleware-composition.integration.test.ts` that boots a real Hono app composing (a) `createAuditMiddleware` from `@suaveplan/api-plugin-audit` mounted globally, (b) `rateLimit` from `@suaveplan/api-plugin-rate-limit` mounted on the site prefix, and (c) `createPrivateSiteRoutes` from this package mounted under the same prefix. The test SHALL exercise a `POST /sites` write call and SHALL assert that (1) the site row is persisted (site behaviour unchanged by middleware composition), (2) the response carries at least one `X-RateLimit-*` header (rate-limit observed the request), and (3) exactly one `AuditEvent` is recorded in the in-memory audit sink (audit observed the request).
#### Scenario: composed write path preserves site behaviour, surfaces rate-limit headers, and records one audit event
- **WHEN** the composed Hono app receives `POST /sites` with a valid body and a forwarded-for IP header
- **THEN** the response status is `201`, `res.headers.get("X-RateLimit-Limit")` is non-null, and the `MemoryAuditAdapter` snapshot contains exactly one event whose `details.path === "/sites"` and `details.method === "POST"`
**Verified by:** `src/integration/middleware-composition.integration.test.ts::"composes audit + rate-limit + site routes in the documented order"`
---
### Requirement: Package-level integration guide
The package SHALL ship `docs/middleware-integration.md` documenting the canonical Hono `app.use(prefix, middleware)` composition order (audit outermost → rate-limit middle → site routes innermost), the rationale for that order (audit must observe rate-limit denials; rate-limit must precede expensive handlers), and a copy-pasteable end-to-end example covering all three plugins. The README SHALL link to this guide from a "Middleware composition" subsection.
#### Scenario: guide exists and exceeds 200 words
- **WHEN** the docs-check gate inspects `docs/middleware-integration.md`
- **THEN** the file exists, contains the words `app.use`, `audit`, `rate-limit`, and `site`, and exceeds 200 words excluding code blocks
**Verified by:** `src/integration/middleware-recommendations.test.ts::"docs/middleware-integration.md exists and documents the composition order"`
---

View File

@@ -0,0 +1,268 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-sse
**Package**: `@suaveplan/api-plugin-sse`
**Status**: SHIPPED
**Folded from**: `openspec/archive/2026-05-19-add-api-plugin-sse/specs/api-plugin-sse/spec.md`
> Every requirement below carries a `**Verified by:**` line pointing at concrete test scenarios.
## Requirements
### Requirement: createSsePlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createSsePlugin(options): PluginDescriptor<SsePluginConfig>` that validates `options` against `SsePluginOptionsSchema` at the factory boundary. Invalid input SHALL raise `SseError(SSE_INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "sse"`, the resolved `configSchema`, and the four lifecycle hooks (`onRegister`, `onInit`, `onReady`, `onClose`) of which at least `onInit` and `onClose` are populated.
#### Scenario: factory produces a valid descriptor
- **WHEN** `createSsePlugin({})` is called with defaults
- **THEN** the returned descriptor satisfies `PluginDescriptor<SsePluginConfig>` with `name === "sse"` AND `onInit` / `onClose` defined
#### Scenario: invalid options raise SSE_INVALID_INPUT
- **WHEN** `createSsePlugin({ heartbeatIntervalMs: -1 } as never)` is called
- **THEN** `SseError(SSE_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createSsePlugin returns a valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise SSE_INVALID_INPUT"`
---
### Requirement: onInit registers sseService in ServiceRegistry and mounts default route
The descriptor's `onInit(ctx)` hook SHALL: (1) resolve `app` from `ctx`'s ServiceRegistry — when absent throw `SseError(SSE_SERVICE_NOT_AVAILABLE)` listing `app` as missing; (2) construct a `RingBuffer`, an `InProcessFanout`, and an `SseService`; (3) call `ctx.registerService("sseService", service)` so downstream plugins / route handlers consume it through the canonical registry; (4) when `options.attachDefaultRoute !== false`, mount `GET <routePrefix>/:channel` (default `/sse/:channel`) wired to `createSseRoute(service, ...)`. `onClose` SHALL call `service.close()` to terminate every open connection.
#### Scenario: missing app raises SSE_SERVICE_NOT_AVAILABLE
- **WHEN** the plugin initializes against a context whose `getService("app")` returns `undefined`
- **THEN** `SseError(SSE_SERVICE_NOT_AVAILABLE)` is thrown with `context.missing === ["app"]`
#### Scenario: sseService is registered and the default route is mounted
- **WHEN** the plugin initializes against a context with a Hono `app` registered
- **THEN** `ctx.getService("sseService")` returns the `SseService` AND a `GET /sse/<channel>` request returns `Content-Type: text/event-stream`
#### Scenario: attachDefaultRoute: false skips mounting
- **WHEN** the plugin initializes with `attachDefaultRoute: false`
- **THEN** `ctx.getService("sseService")` returns the service AND no route is registered under `routePrefix`
#### Scenario: onClose terminates every open connection
- **WHEN** the plugin's `onClose` runs after a subscriber is active
- **THEN** `service.stats().activeConnections === 0` after the hook resolves
**Verified by:** `src/plugin/on-init.test.ts::"missing app raises SSE_SERVICE_NOT_AVAILABLE"` AND `src/plugin/on-init.test.ts::"registers sseService and mounts default route"` AND `src/plugin/on-init.test.ts::"attachDefaultRoute false skips route registration"` AND `src/plugin/on-init.test.ts::"onClose terminates active connections"`
---
### Requirement: SseService exposes broadcast / subscribe / close / stats
The package SHALL register `sseService` in the ServiceRegistry with the typed surface `{ broadcast(channel, event): void; subscribe(args): SubscriptionHandle; close(channel?): Promise<void>; stats(): SseStats }`. `broadcast` SHALL append the event to the channel's ring buffer with an id supplied by the configured `idGenerator` (when the event has no id) AND publish it through the `Fanout` so every active subscriber to that channel receives it. `subscribe` SHALL: (a) assign a connection id; (b) enforce `maxTotalConnections` (throw `SseError(SSE_TOO_MANY_CONNECTIONS)`) and `maxConnectionsPerPrincipal` (throw `SseError(SSE_PER_PRINCIPAL_LIMIT_EXCEEDED)`); (c) when `lastEventId` is supplied, replay the buffered events strictly after that id before forwarding live events; (d) return a `SubscriptionHandle` exposing `id`, `events` (an `AsyncIterableIterator<SseEvent>`), and `close()`. `close(channel?)` SHALL terminate every subscription (optionally restricted to a single channel) and resolve once their iterators have completed. `stats()` SHALL return `{ activeConnections: number; perChannel: Record<string, number> }`.
#### Scenario: broadcast appends to buffer and fans out to subscribers
- **WHEN** a subscriber is active on channel `notifications` and `service.broadcast("notifications", { data: { kind: "ping" } })` is called
- **THEN** the subscriber's iterator yields an `SseEvent` with `data.kind === "ping"` AND the buffer's `since("notifications", "")` includes that event
#### Scenario: subscribe with Last-Event-ID replays missed events
- **WHEN** broadcasts produce ids `1`, `2`, `3` on a channel and a new subscriber connects with `lastEventId: "1"`
- **THEN** the iterator yields events `2` and `3` in order before any live event
#### Scenario: maxConnectionsPerPrincipal enforces per-principal cap
- **WHEN** `principalId: "u1"` already holds the configured cap and another `subscribe` is attempted with the same principalId
- **THEN** `SseError(SSE_PER_PRINCIPAL_LIMIT_EXCEEDED)` is thrown with `context.principalId === "u1"`
#### Scenario: maxTotalConnections enforces global cap
- **WHEN** `activeConnections === maxTotalConnections` and another `subscribe` is attempted
- **THEN** `SseError(SSE_TOO_MANY_CONNECTIONS)` is thrown with `context.max` carrying the cap
#### Scenario: close terminates active connections
- **WHEN** `service.close("notifications")` is called with two subscribers on that channel
- **THEN** both iterators complete AND `service.stats().perChannel["notifications"]` is `undefined` or `0`
**Verified by:** `src/services/service.test.ts::"broadcast appends to buffer and fans out to subscribers"` AND `src/services/service.test.ts::"subscribe with Last-Event-ID replays missed events"` AND `src/services/service.test.ts::"maxConnectionsPerPrincipal enforces per-principal cap"` AND `src/services/service.test.ts::"maxTotalConnections enforces global cap"` AND `src/services/service.test.ts::"close terminates active connections"`
---
### Requirement: Hono SSE handler streams text/event-stream with heartbeats, abort, and resume
`createSseRoute(service, options)` SHALL return a Hono `Handler` that: (a) reads `:channel` from the path; missing → throw `SseError(SSE_CHANNEL_REQUIRED)`; (b) reads `Last-Event-ID` from request headers; (c) calls `service.subscribe({ channel, lastEventId, principalId: options.resolvePrincipalId?.(c), signal: c.req.raw.signal })`; (d) returns a Hono `streamSSE` response whose body writes one `id: <id>\nevent: <event>\ndata: <json>\n\n` frame per event AND a `: heartbeat\n\n` comment on the configured `heartbeatIntervalMs`; (e) on stream abort (client disconnect), unsubscribes by calling `handle.close()`; (f) on backpressure detection (`controller.desiredSize === 0` for longer than one heartbeat tick), drops or buffers per `overflowPolicy` and increments `sse.queue_overflow_total{policy}`. The response status SHALL be `200` with `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, and `Connection: keep-alive`.
#### Scenario: handler streams framed events to a real fetch client
- **WHEN** a real Hono app with the plugin mounted serves `GET /sse/notifications` and a `fetch` client subscribes, then `service.broadcast("notifications", { data: { n: 1 } })`, `{ data: { n: 2 } }`, `{ data: { n: 3 } }` run
- **THEN** the client receives three `SseEvent`s in order with ids `1`, `2`, `3` (or whatever the configured idGenerator emits) AND the response carries the SSE content-type headers above
#### Scenario: Last-Event-ID resumes from the ring buffer
- **WHEN** the client reconnects with `Last-Event-ID: 2` after the same broadcasts
- **THEN** the client receives only event `3` before live events begin
#### Scenario: heartbeats fire on the configured interval
- **WHEN** `heartbeatIntervalMs` elapses on an open connection with a FakeClock-driven timer
- **THEN** the client observes a `: heartbeat\n\n` comment frame AND `sse.heartbeats_total` increments by one
#### Scenario: abort signal closes the subscription
- **WHEN** the client `AbortController.abort()`s mid-stream
- **THEN** `service.stats().activeConnections` drops by one within one event-loop tick
**Verified by:** `src/middleware/sse-handler.test.ts::"streams framed events to a real fetch client"` AND `src/middleware/sse-handler.test.ts::"Last-Event-ID resumes from the ring buffer"` AND `src/middleware/sse-handler.test.ts::"heartbeats fire on the configured interval"` AND `src/middleware/sse-handler.test.ts::"abort signal closes the subscription"` AND `tests/integration/full-stream.test.ts::"end-to-end fetch subscriber receives three ordered events"` AND `tests/integration/full-stream.test.ts::"reconnect with Last-Event-ID skips delivered events"`
---
### Requirement: SSE wire-format serializer and streaming parser
The package SHALL export `formatSseFrame(event: SseEvent): string` that serialises an event into the SSE wire format (`id: <id>\n`, optional `event: <name>\n`, optional `retry: <ms>\n`, `data: <serialised>\n\n`). `data` SHALL be JSON-stringified when not already a string; multi-line string `data` SHALL be split into one `data:` line per source line. The package SHALL also export `parseSseStream(reader)` — an async iterator that decodes a `ReadableStreamDefaultReader<Uint8Array>` into `SseEvent`s, used by the integration test client and by any consumer that needs to parse SSE.
#### Scenario: formatSseFrame emits id, event, data with correct delimiters
- **WHEN** `formatSseFrame({ id: "42", event: "ping", data: { n: 1 } })` is called
- **THEN** the result equals `"id: 42\nevent: ping\ndata: {\"n\":1}\n\n"`
#### Scenario: multi-line string data is split into multiple data: lines
- **WHEN** `formatSseFrame({ id: "1", data: "a\nb" })` is called
- **THEN** the result contains `"data: a\ndata: b\n"`
#### Scenario: parseSseStream yields events for each frame
- **WHEN** a stream emits two frames separated by a blank line
- **THEN** the iterator yields two `SseEvent`s with the parsed `id`, `event`, and `data` fields
**Verified by:** `src/formatter/format.test.ts::"emits id, event, data with correct delimiters"` AND `src/formatter/format.test.ts::"multi-line string data splits into multiple data lines"` AND `src/formatter/parse.test.ts::"yields events for each frame in the stream"`
---
### Requirement: Bounded per-channel ring buffer powers replay
`createRingBuffer(retentionPerChannel)` SHALL return an object with `append(channel: string, event: SseEvent): void` and `since(channel: string, lastEventId: string): readonly SseEvent[]`. The buffer SHALL retain at most `retentionPerChannel` events per channel (oldest evicted on overflow). `since` SHALL return all events whose ids strictly follow `lastEventId` (oldest first) and an empty array when `lastEventId` is not found in the buffer (so a stale client starts fresh rather than receiving the entire buffer).
#### Scenario: append + since returns events after the cursor
- **WHEN** `append("c", { id: "1", data: {} })`, `append("c", { id: "2", data: {} })`, `append("c", { id: "3", data: {} })` and `since("c", "1")` is called
- **THEN** the returned array contains events with ids `2` and `3` in order
#### Scenario: retention rolls over
- **WHEN** `retention = 2` and three events are appended
- **THEN** `since("c", "")` returns the latest two events only
#### Scenario: unknown lastEventId returns empty
- **WHEN** `since("c", "non-existent")` is called against a non-empty buffer
- **THEN** the returned array is empty
**Verified by:** `src/buffer/ring-buffer.test.ts::"append + since returns events after the cursor"` AND `src/buffer/ring-buffer.test.ts::"retention rolls over"` AND `src/buffer/ring-buffer.test.ts::"unknown lastEventId returns empty"`
---
### Requirement: Fanout interface and in-process implementation
The package SHALL export a `Fanout` interface — `publish(channel: string, event: SseEvent): void` and `subscribe(channel: string, listener: (event: SseEvent) => void): () => void` — and a concrete `InProcessFanout` implementation. `subscribe` SHALL return an unsubscribe function; calling it removes the listener and prevents future deliveries.
#### Scenario: publish delivers to every subscribed listener for the channel
- **WHEN** two listeners are subscribed on `"a"` and one on `"b"`, then `publish("a", event)` fires
- **THEN** both `"a"` listeners receive the event AND the `"b"` listener does not
#### Scenario: unsubscribe stops further deliveries
- **WHEN** a listener subscribes, then its returned unsubscribe is called, then `publish` runs
- **THEN** the listener does not receive the event
**Verified by:** `src/fanout/in-process.test.ts::"publish delivers to every listener for the channel"` AND `src/fanout/in-process.test.ts::"unsubscribe stops further deliveries"`
---
### Requirement: Zod schemas validate options and event payloads at the boundary
The package SHALL export `SsePluginOptionsSchema` (Zod) validating every field of `SsePluginOptions` (positive integers for limits, positive `heartbeatIntervalMs`, `overflowPolicy` enum, optional functions). The package SHALL export `SseEventSchema` (Zod) validating the `SseEvent` shape used by callers of `service.broadcast`. Invalid input to either schema SHALL flow through `SseError(SSE_INVALID_INPUT)`.
#### Scenario: SsePluginOptionsSchema rejects non-positive heartbeat
- **WHEN** `SsePluginOptionsSchema.safeParse({ heartbeatIntervalMs: 0 })` is called
- **THEN** the result has `success === false` with an issue on `heartbeatIntervalMs`
#### Scenario: SseEventSchema accepts arbitrary JSON-serialisable data
- **WHEN** `SseEventSchema.safeParse({ id: "1", data: { nested: { arr: [1, 2] } } })` is called
- **THEN** `success === true`
**Verified by:** `src/schemas/options.test.ts::"rejects non-positive heartbeat"` AND `src/schemas/event.test.ts::"accepts arbitrary JSON data"`
---
### Requirement: SseError registry with eight stable codes thrown from production paths
The package SHALL declare `SSE_CODES = defineCodes("SSE", [...])` covering `SSE_UNAUTHENTICATED`, `SSE_TOO_MANY_CONNECTIONS`, `SSE_PER_PRINCIPAL_LIMIT_EXCEEDED`, `SSE_CHANNEL_REQUIRED`, `SSE_SERVICE_NOT_AVAILABLE`, `SSE_INVALID_INPUT`, `SSE_QUEUE_OVERFLOW`, `SSE_CLOSED`. Every code SHALL be reachable from at least one production call site (asserted by the spec-conformance test).
#### Scenario: every code is thrown by a production path
- **WHEN** the spec-conformance suite exercises the documented trigger for each code
- **THEN** an `SseError` instance is thrown carrying that code
**Verified by:** `src/error-codes/error-codes.test.ts::"every SSE_* code is reachable from a production path"` AND `src/api-plugin-sse.spec-conformance.test.ts::"every SSE_* code is thrown from src/"`
---
### Requirement: OpenTelemetry instruments are emitted from production paths
The package SHALL register OTel instruments via `getSseInstruments()` and emit each from production code: `sse.active_connections` (UpDownCounter — +1 on subscribe, −1 on close), `sse.broadcasts_total{channel}` (Counter on broadcast), `sse.heartbeats_total` (Counter on heartbeat tick), `sse.message_bytes_total` (Counter on every framed write), `sse.queue_overflow_total{policy}` (Counter on overflow), `sse.connection_duration_ms` (Histogram on close). The conformance test SHALL install a real in-memory `MeterProvider`, exercise each path, and assert the metric was recorded.
#### Scenario: subscribe + close moves active_connections up and back down
- **WHEN** a subscription is created and then closed against a real `MeterProvider`
- **THEN** the collected metrics show `sse.active_connections` increased by 1 then decreased by 1
#### Scenario: broadcast increments broadcasts_total{channel}
- **WHEN** `service.broadcast("notifications", event)` is called
- **THEN** the collected metrics show `sse.broadcasts_total` with attribute `channel === "notifications"` incremented by 1
**Verified by:** `src/otel/instruments.test.ts::"subscribe + close emits active_connections"` AND `src/otel/instruments.test.ts::"broadcast emits broadcasts_total"`
---
### Requirement: Backpressure honors queueHighWaterMark and overflowPolicy
The Hono SSE handler SHALL track per-connection write-queue depth. When the writable controller's `desiredSize` falls to zero or the in-memory backlog exceeds `queueHighWaterMark`, the handler SHALL apply `overflowPolicy`: `"drop-oldest"` removes the oldest queued event before enqueueing the new one; `"drop-newest"` discards the incoming event. In both cases, `sse.queue_overflow_total{policy}` SHALL increment.
#### Scenario: drop-oldest evicts the oldest queued event
- **WHEN** `queueHighWaterMark = 2`, `overflowPolicy = "drop-oldest"`, and three events arrive faster than the controller drains
- **THEN** the third event lands in the queue AND the first is dropped AND `sse.queue_overflow_total{policy: "drop-oldest"}` increments
**Verified by:** `src/middleware/sse-handler.test.ts::"drop-oldest evicts the oldest queued event under backpressure"`
---
### Requirement: ContextVariableMap untouched; subscriber identity flows via options.resolvePrincipalId
The package SHALL NOT augment Hono's global `ContextVariableMap`. Subscriber principal identity SHALL be resolved via `options.resolvePrincipalId?: (c: Context) => string | undefined` passed to `createSseRoute`, so consumers wiring `@suaveplan/api-plugin-auth` upstream can read `c.get("user")?.id` and feed it in without this plugin depending on the auth package.
#### Scenario: resolvePrincipalId is invoked once per subscription
- **WHEN** a request reaches the handler and `resolvePrincipalId` is configured
- **THEN** the resolver is called with the Hono `Context` and the returned value is passed to `service.subscribe` as `principalId`
**Verified by:** `src/middleware/sse-handler.test.ts::"resolvePrincipalId is invoked once per subscription"`
---
### Requirement: Subpath exports per feature module
The package SHALL declare subpath exports in `package.json` so each feature module is independently importable: `.`, `./plugin/plugin`, `./services/service`, `./middleware/sse-handler`, `./formatter/format`, `./formatter/parse`, `./buffer/ring-buffer`, `./fanout/in-process`, `./schemas/options`, `./schemas/event`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`, `./registry/service-registry`.
#### Scenario: dep-graph test enumerates expected subpaths
- **WHEN** `tests/dep-graph.test.ts` reads `package.json`'s `exports` map
- **THEN** the list of `./*` entries matches the documented set exactly
**Verified by:** `tests/dep-graph.test.ts::"exports map advertises every feature subpath"`

View File

@@ -0,0 +1,250 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin-tenant
**Package**: `@suaveplan/api-plugin-tenant`
**Status**: SHIPPED (folded from `add-api-plugin-tenant` on 2026-05-19)
> Every requirement carries a `**Verified by:**` line pointing at the
> concrete `it(...)` titles in the package's test suite.
## Requirements
### Requirement: createTenantPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createTenantPlugin(options): PluginDescriptor<TenantPluginConfig>`
that validates `options` against `TenantPluginOptionsSchema` (Zod) at the factory
boundary. Invalid input SHALL raise `TenantPluginError(TENANT_PLUGIN_INVALID_INPUT)`
carrying the `ZodError` as `cause`. The returned descriptor SHALL declare
`name: "tenant"`, the resolved `defaults`, and the 4-phase lifecycle hooks
(`onInit`, `onClose`).
#### Scenario: factory produces a valid descriptor
- **WHEN** `createTenantPlugin({ strategies: [{ type: "header", headerName: "x-tenant-id" }], store: new MemoryTenantStore() })` is called
- **THEN** the returned descriptor satisfies `PluginDescriptor<TenantPluginConfig>` with `name === "tenant"` and `onInit` defined
#### Scenario: invalid options raise TENANT_PLUGIN_INVALID_INPUT
- **WHEN** `createTenantPlugin({} as never)` is called (missing `strategies`)
- **THEN** `TenantPluginError(TENANT_PLUGIN_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createTenantPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise TENANT_PLUGIN_INVALID_INPUT"`
---
### Requirement: onInit composes the resolver chain from declared strategies
The descriptor's `onInit(ctx)` hook SHALL compose a `ChainedTenantResolver`
from the strategy specs in priority order, binding each strategy to a lookup
function derived from the configured `TenantStore`. When `options.cache` is
supplied, the chain SHALL be wrapped with `TenantCache.wrap(chain, cache)`.
When no `store` is supplied, the chain SHALL still be built but every lookup
returns `null`, and `onInit` SHALL log a warning.
#### Scenario: header beats query when both present in chain order
- **WHEN** the strategy list is `[{ type: "header", headerName: "x-tenant-id" }, { type: "query", paramName: "tenant" }]` and a request carries BOTH a header and a query parameter
- **THEN** the resolved tenant matches the header value (chain returns on first hit)
#### Scenario: cache wraps the chain when configured
- **WHEN** `options.cache: { ttlMs: 5000 }` is supplied
- **THEN** the resolver's `name` is `cached(chained)` and repeated resolutions for the same key hit the cache (`tenant_plugin.cache_total{outcome: "hit"}` increments)
**Verified by:** `src/plugin/on-init.test.ts::"resolver chain returns first matching strategy (header beats query)"` AND `src/plugin/on-init.test.ts::"cache wraps the chain when configured"`
---
### Requirement: onInit registers the tenantService slot
After resolver construction, `onInit` SHALL register a `tenantService` entry
in the host `ServiceRegistry` exposing `resolveFrom(c)`, `requireFrom(c)`,
`current(c)`, `getById(id)`, `listAll(query?)`, `createTenant(input)`,
`updateTenant(id, patch)`, `deleteTenant(id)`, and `getResolver()`. The
CRUD methods SHALL throw `TenantPluginError(TENANT_PLUGIN_STORE_NOT_CONFIGURED)`
when no `store` option was supplied.
#### Scenario: tenantService accessible to downstream plugins
- **WHEN** `onInit` completes successfully
- **THEN** `ctx.getService("tenantService")` returns the registered service AND `service.getResolver().name` ends with `chained` or `cached(chained)`
#### Scenario: CRUD without store raises STORE_NOT_CONFIGURED
- **WHEN** `createTenantPlugin({ strategies: [...] })` is wired without `store`, and `service.createTenant({...})` is called
- **THEN** `TenantPluginError(TENANT_PLUGIN_STORE_NOT_CONFIGURED)` is thrown
**Verified by:** `src/services/tenant-service.test.ts::"tenantService surface exposes resolver + CRUD"` AND `src/services/tenant-service.test.ts::"CRUD without store raises TENANT_PLUGIN_STORE_NOT_CONFIGURED"`
---
### Requirement: onInit installs the global tenant-resolving middleware
`onInit` SHALL call `ctx.app.use("*", createTenantMiddleware({ resolver,
required, excludePaths, allowSuspended }))` from `@suaveplan/api-tenant` so
every request gets `c.set("tenant", tenant)` populated. When `required: false`
(default) and no tenant resolves, the middleware leaves `c.get("tenant")` as
`undefined` and downstream code falls back to `NullTenantContext` via
`createTenantContext`. When `required: true` and resolution fails, the
middleware raises `TenantError(TENANT.NOT_FOUND)` (404).
#### Scenario: per-request isolation — different headers resolve to different tenants
- **WHEN** two sequential requests carry distinct `x-tenant-id` values
- **THEN** each handler observes `c.get("tenant")` matching the inbound header value
#### Scenario: required=true with no tenant raises NOT_FOUND
- **WHEN** `required: true` and a request lacks every configured strategy
- **THEN** the middleware raises `TenantError(TENANT.NOT_FOUND)` mapping to HTTP 404
#### Scenario: required=false leaves c.get("tenant") undefined for unresolved requests
- **WHEN** `required: false` (default) and a request lacks every configured strategy
- **THEN** the handler runs AND `createTenantContext(c.get("tenant") ?? NullTenantContext.tenant)` defaults to fail-closed checks
**Verified by:** `src/middleware/tenant.test.ts::"per-request isolation across distinct header values"` AND `src/middleware/tenant.test.ts::"required=true with no tenant raises TENANT.NOT_FOUND"` AND `src/middleware/tenant.test.ts::"required=false leaves c.get(\"tenant\") undefined for unresolved requests"`
---
### Requirement: Management routes mounted only when store is configured
When `options.store` is supplied, `onInit` SHALL register five CRUD routes
under `options.routePrefix` (default `/tenants`) via `definePluginRoutes`:
`GET /tenants`, `POST /tenants`, `GET /tenants/:id`, `PATCH /tenants/:id`,
`DELETE /tenants/:id`. Every body / query / response SHALL flow through Zod
schemas. When `store` is absent, the routes SHALL NOT be registered.
#### Scenario: store configured — CRUD routes are mounted
- **WHEN** `createTenantPlugin({ strategies, store: new MemoryTenantStore() })` initializes against an `OpenAPIRegistry`
- **THEN** `registry.getRoutesByPlugin("tenant")` lists 5 routes under `/tenants`
#### Scenario: store absent — CRUD routes are not mounted
- **WHEN** `createTenantPlugin({ strategies })` (no store) initializes
- **THEN** `registry.getRoutesByPlugin("tenant")` is empty
#### Scenario: routePrefix collision raises TENANT_PLUGIN_ROUTE_PREFIX_COLLISION
- **WHEN** another plugin has already registered routes under the same prefix
- **THEN** `TenantPluginError(TENANT_PLUGIN_ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix`
**Verified by:** `src/routes/routes.test.ts::"CRUD routes mount when store is configured"` AND `src/routes/routes.test.ts::"no CRUD routes mount when store is absent"` AND `src/plugin/on-init.test.ts::"routePrefix collision raises TENANT_PLUGIN_ROUTE_PREFIX_COLLISION"`
---
### Requirement: Management routes are RBAC-gated via api-plugin-auth
When `requireRole` from `@suaveplan/api-plugin-auth` is available (the auth
plugin's `authService` is registered in the host registry), the CRUD routes
SHALL be wrapped with `requireRole("admin")` so only principals carrying the
`admin` role can hit them. When `authService` is absent, the plugin SHALL
mount the routes WITHOUT the guard AND log a warning identifying the missing
service so operators know to add their own guard.
#### Scenario: admin role is enforced when authService is registered
- **WHEN** `authService` is registered and a request to `POST /tenants` carries `c.get("user")` with `roles: []`
- **THEN** the response is HTTP 403 with `AUTH.INSUFFICIENT_ROLE`
#### Scenario: warning emitted when authService is missing
- **WHEN** `authService` is NOT registered and the plugin initializes with a store
- **THEN** `ctx.logger.warn` is called with a message naming `authService`
**Verified by:** `src/routes/routes.test.ts::"admin role is enforced when authService is registered"` AND `src/plugin/on-init.test.ts::"warning emitted when authService is missing"`
---
### Requirement: MemoryTenantStore implements TenantStore with slug uniqueness
The package SHALL ship `MemoryTenantStore implements TenantStore`. `create`
SHALL reject inputs whose `slug` collides with an existing tenant with
`TenantPluginError(TENANT_PLUGIN_TENANT_SLUG_TAKEN)`. `update(id, patch)`
SHALL raise `TenantPluginError(TENANT_PLUGIN_TENANT_NOT_FOUND)` when the id
is unknown. `delete(id)` SHALL be soft (sets `status: "deleted"`) and
idempotent.
#### Scenario: create succeeds for a fresh slug
- **WHEN** `store.create({ name, slug })` runs against an empty store
- **THEN** the returned tenant carries the supplied slug AND `getById` returns it
#### Scenario: create rejects duplicate slug
- **WHEN** a second `store.create` reuses an existing `slug`
- **THEN** `TenantPluginError(TENANT_PLUGIN_TENANT_SLUG_TAKEN)` is raised
#### Scenario: delete is soft and idempotent
- **WHEN** `delete(id)` is called twice
- **THEN** the second call does not throw AND `getById(id)?.status === "deleted"`
**Verified by:** `src/store/memory.test.ts::"create succeeds for a fresh slug"` AND `src/store/memory.test.ts::"create rejects duplicate slug"` AND `src/store/memory.test.ts::"delete is soft and idempotent"`
---
### Requirement: OTel instruments cover resolution, cache, and management ops
The package SHALL emit `tenant_plugin.resolutions_total{outcome,strategy}`,
`tenant_plugin.resolution_duration_ms` (histogram),
`tenant_plugin.cache_total{outcome}`, and
`tenant_plugin.management_ops_total{op,outcome}` from the production hot
paths. The instruments SHALL be resolved lazily from the global
`MeterProvider` so tests can install an in-memory provider before the package
is imported.
#### Scenario: resolution increments counter and histogram
- **WHEN** the tenant middleware runs on a request that resolves a tenant
- **THEN** `tenant_plugin.resolutions_total{outcome: "resolved"}` increments AND `tenant_plugin.resolution_duration_ms` records a sample
#### Scenario: cache hit increments cache counter
- **WHEN** the same key is resolved twice with caching enabled
- **THEN** the second call increments `tenant_plugin.cache_total{outcome: "hit"}`
#### Scenario: management op records outcome
- **WHEN** `service.createTenant(input)` succeeds
- **THEN** `tenant_plugin.management_ops_total{op: "create", outcome: "success"}` increments
**Verified by:** `src/otel/instruments.test.ts::"resolution increments counter and histogram"` AND `src/otel/instruments.test.ts::"cache hit increments cache counter"` AND `src/otel/instruments.test.ts::"management op records outcome"`
---
### Requirement: ServiceRegistry augmentation declares tenantService slot
The package SHALL declare `tenantService: TenantService` on
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via a
side-effect-imported module-augmentation file. Importing
`@suaveplan/api-plugin-tenant` SHALL be sufficient to make
`ctx.getService("tenantService")` resolve to `TenantService | undefined`
without any cast.
#### Scenario: tenantService key types as TenantService
- **WHEN** a consumer imports `@suaveplan/api-plugin-tenant` and calls `ctx.getService("tenantService")`
- **THEN** the return type narrows to `TenantService | undefined` at compile time
**Verified by:** `src/api-plugin-tenant.spec-conformance.test.ts::"tenantService key resolves to TenantService type via augmentation"`
---
### Requirement: Side-effect registration of error codes + augmentation from index
`src/index.ts` SHALL side-effect-import `./error-codes/error-codes.js` and
`./services/registry-augmentation.js` so a consumer importing the package
barrel gets the code registry surfaced AND the `ServiceRegistry` slot typed
without authoring a subpath import.
#### Scenario: importing the barrel surfaces error codes + registry slot
- **WHEN** a consumer does `import { createTenantPlugin } from "@suaveplan/api-plugin-tenant"`
- **THEN** `TENANT_PLUGIN_CODES.INVALID_INPUT` is reachable AND `ctx.getService("tenantService")` types correctly
**Verified by:** `src/api-plugin-tenant.spec-conformance.test.ts::"barrel side-effect imports surface error codes and registry slot"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,142 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-user (add-api-plugin-user)
**Package**: `@suaveplan/api-plugin-user`
**Status**: ACTIVE (folded 2026-05-18 from add-api-plugin-user)
## ADDED Requirements
### Requirement: createUserPlugin returns a Zod-validated descriptor with auth dependency
The package SHALL export `createUserPlugin(options): PluginDescriptor<UserPluginConfig>` that Zod-validates `options` via `UserPluginOptionsSchema`. The descriptor SHALL declare `name: "user"` and `dependencies: ["auth"]` so the registry topo-sorts after `api-plugin-auth`. Invalid options raise `UserError(USER_INVALID_INPUT)` with `cause instanceof ZodError`.
#### Scenario: descriptor declares auth dependency
- **WHEN** `createUserPlugin({})` is called
- **THEN** the returned descriptor has `dependencies` containing `"auth"`
#### Scenario: invalid options raise USER_INVALID_INPUT
- **WHEN** `createUserPlugin({ allowSelfDelete: "yes" as never })` is called
- **THEN** `UserError(USER_INVALID_INPUT)` is thrown with ZodError as cause
**Verified by:** `src/plugin/plugin.test.ts::"descriptor declares auth dependency"` AND `src/plugin/plugin.test.ts::"invalid options raise USER_INVALID_INPUT"`
---
### Requirement: onInit applies USER_PROFILE_SCHEMA (plug-and-play point 1)
`onInit(ctx)` SHALL resolve `migrationRunner` (preferred) or `db` (fallback) from `ServiceRegistry`. When `applySchemaOnInit === true` (default), it SHALL render `USER_PROFILE_SCHEMA` from the configured `profileFields` and apply it. Missing services raise `UserError(USER_SERVICE_NOT_AVAILABLE)` listing missing names.
#### Scenario: schema extends user table with profile fields
- **WHEN** `createUserPlugin({ profileFields: ["bio", "locale"] })` initializes against a real SurrealDB testcontainer with the auth schema applied first
- **THEN** the `user` table now has `bio` and `locale` as `option<string>` fields AND existing rows are unchanged
#### Scenario: applySchemaOnInit: false skips the step
- **WHEN** `createUserPlugin({ applySchemaOnInit: false })` initializes
- **THEN** the schema apply step is skipped (operator manages externally)
**Verified by:** `src/plugin/on-init.test.ts::"applies USER_PROFILE_SCHEMA via migrationRunner"` AND `src/plugin/on-init.test.ts::"profileFields override extends specified columns"` AND `src/plugin/on-init.test.ts::"applySchemaOnInit: false skips apply"`
---
### Requirement: 3 routes registered with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)
`onInit` SHALL call `definePluginRoutes(buildUserRoutes(authService), { app, registry, pluginName: "user" })` registering: `GET /users/me`, `PATCH /users/me`, `DELETE /users/me`. Each route SHALL use `requireAuth()` middleware upstream. Each request body / response SHALL be a Zod-typed schema from `./schemas/user-profile`. `DELETE /users/me` SHALL invoke `authService.getBetterAuthInstance().api.removeUser({ userId })` for cascade.
#### Scenario: GET /users/me returns Principal + profile fields
- **WHEN** an authenticated request hits `GET /users/me`
- **THEN** the response is `UserProfileResponseSchema`-shaped: `{ id, email, emailVerified, roles, displayName?, avatarUrl?, locale?, timezone? }`
#### Scenario: PATCH /users/me rejects unknown fields
- **WHEN** an authenticated request PATCHes `{ displayName: "X", evil: "Y" }`
- **THEN** the response is 400 from `UpdateUserProfileBodySchema` Zod validation (strict mode); the user row is unchanged
#### Scenario: DELETE /users/me cascades via Better-Auth
- **WHEN** `allowSelfDelete: true` (default) and an authenticated DELETE hits `/users/me`
- **THEN** `authService.getBetterAuthInstance().api.removeUser({ userId: principal.id })` is invoked; the response is `{ deleted: true }`; subsequent `findOne({ model: "user", where: [{ field: "id", value: principal.id }] })` returns null AND every `session`/`account` for that user is gone too
#### Scenario: DELETE /users/me disabled raises USER_SELF_DELETE_DISABLED
- **WHEN** `allowSelfDelete: false` and an authenticated DELETE hits `/users/me`
- **THEN** the response is 403 with `UserError(USER_SELF_DELETE_DISABLED)`
**Verified by:** `src/routes/get-me.test.ts::"returns Principal + profile fields"` AND `src/routes/patch-me.test.ts::"rejects unknown fields"` AND `src/routes/delete-me.test.ts::"cascades via Better-Auth"` AND `src/routes/delete-me.test.ts::"disabled raises USER_SELF_DELETE_DISABLED"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes /users/me routes with Zod-typed schemas"`
---
### Requirement: userService registered with typed slot
`onInit` SHALL register `ctx.registerService("userService", { getById, getCurrent, updateProfile })`. ServiceRegistry module augmentation adds `userService: UserService` so downstream plugins resolve it with full type inference.
#### Scenario: getById returns null on missing user
- **WHEN** `userService.getById("missing-id")` is awaited
- **THEN** the result is `null`
#### Scenario: getCurrent reads c.get("user")
- **WHEN** `userService.getCurrent(c)` runs on an authenticated request
- **THEN** the result equals the merge of `c.get("user")` + the row's profile fields
**Verified by:** `src/services/user-service.test.ts::"getById returns null on missing"` AND `src/services/user-service.test.ts::"getCurrent reads c.get user"` AND `src/services/user-service.spec-conformance.test.ts::"ServiceRegistry slot typed as UserService"`
---
### Requirement: UserProfileSchema extends PrincipalSchema (plug-and-play point 2)
The package SHALL export `UserProfileSchema` (Zod) at `./schemas/user-profile` that extends `PrincipalSchema` re-imported from `@suaveplan/api-plugin-auth/schemas/principal` with optional profile fields `displayName`, `avatarUrl`, `locale`, `timezone`. `UpdateUserProfileBodySchema` SHALL be `UserProfileSchema.pick(profileFields).partial().strict()` — operator-controlled subset, strict-mode rejects unknown keys.
#### Scenario: UserProfileSchema imports PrincipalSchema from sibling
- **WHEN** the conformance test grep's `src/schemas/user-profile.ts` for an import from `@suaveplan/api-plugin-auth/schemas/principal`
- **THEN** the import exists (no duplicated Principal type)
#### Scenario: UpdateUserProfileBodySchema rejects extra fields
- **WHEN** `UpdateUserProfileBodySchema.safeParse({ displayName: "X", evil: "Y" })` is called
- **THEN** `success === false` (strict-mode failure on `evil`)
**Verified by:** `src/schemas/user-profile.test.ts::"extends PrincipalSchema from sibling"` AND `src/schemas/user-profile.test.ts::"UpdateUserProfileBodySchema is strict"` AND `src/schemas/user-profile.spec-conformance.test.ts::"no duplicated Principal definition"`
---
### Requirement: Every USER_* code is thrown from a production path
`USER_CODES` SHALL define exactly 7 codes (`USER_NOT_FOUND`, `USER_INVALID_INPUT`, `USER_SELF_DELETE_DISABLED`, `USER_PROFILE_UPDATE_FAILED`, `USER_SERVICE_NOT_AVAILABLE`, `USER_SCHEMA_APPLY_FAILED`, `USER_ROUTE_PREFIX_COLLISION`). Every code SHALL be raised by ≥1 production call site.
**Verified by:** `src/api-plugin-user.spec-conformance.test.ts::"every USER_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry instruments emit per route
The package SHALL register counters `user.profile_reads_total`, `user.profile_updates_total{outcome}`, `user.self_deletes_total`. Each route handler emits the appropriate counter on completion.
#### Scenario: profile_reads_total emits per GET /users/me
- **WHEN** `GET /users/me` succeeds against a real in-memory `MeterProvider`
- **THEN** `user.profile_reads_total` has 1 observation
**Verified by:** `src/otel/instruments.test.ts::"profile_reads_total emits"` AND `src/otel/instruments.test.ts::"profile_updates_total emits success+error"` AND `src/otel/instruments.test.ts::"self_deletes_total emits"`
---
### Requirement: Server-tier package shape + full integration test
Package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise the full plugin in a real `PluginRegistry` alongside `api-plugin-auth` against a SurrealDB testcontainer: sign-up via auth, then GET/PATCH/DELETE `/users/me` via this plugin.
#### Scenario: full integration boot with auth
- **WHEN** `tests/integration/full-boot.test.ts` builds a real app with `createAuthPlugin` + `createUserPlugin` against SurrealDB testcontainer, sign-up, GET /users/me, PATCH displayName, DELETE /users/me
- **THEN** every step succeeds AND a subsequent GET /users/me returns 401 AUTH_UNAUTHENTICATED (session cascaded)
**Verified by:** `tests/integration/full-boot.test.ts::"sign-up → /users/me → PATCH → DELETE roundtrip against real SurrealDB"` AND `tests/integration/openapi-doc.test.ts::"served doc lists 3 /users/me routes"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,178 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-vault (add-api-plugin-vault)
**Package**: `@suaveplan/api-plugin-vault`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: createVaultPlugin returns a Zod-validated descriptor with auth dependency
The package SHALL export `createVaultPlugin(options): PluginDescriptor<VaultPluginConfig>` Zod-validating `options` via `VaultPluginOptionsSchema`. The descriptor SHALL declare `name: "vault"` and `dependencies: ["auth"]`. Invalid options raise `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` with `cause instanceof ZodError`.
#### Scenario: descriptor declares auth dependency
- **WHEN** `createVaultPlugin({ storage })` is called with a valid storage
- **THEN** the descriptor's `dependencies` contains `"auth"`
#### Scenario: missing storage raises VAULT_PLUGIN_INVALID_INPUT
- **WHEN** `createVaultPlugin({} as never)` is called
- **THEN** `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` is thrown
**Verified by:** `src/plugin/plugin.test.ts::"descriptor declares auth dependency"` AND `src/plugin/plugin.test.ts::"missing storage raises VAULT_PLUGIN_INVALID_INPUT"`
---
### Requirement: onInit applies VAULT_OWNERSHIP_SCHEMA (plug-and-play point 1)
`onInit(ctx)` SHALL resolve `migrationRunner` (preferred) or `db` (fallback) from `ServiceRegistry`. When `applySchemaOnInit === true` (default), `VAULT_OWNERSHIP_SCHEMA` is applied; both `vault_item` and `vault_share` tables created with their indexes. Missing services raise `VaultPluginError(VAULT_PLUGIN_SERVICE_NOT_AVAILABLE)`.
#### Scenario: schema creates vault_item and vault_share tables
- **WHEN** the plugin initializes against a real SurrealDB testcontainer (with the auth schema applied first)
- **THEN** `INFO FOR DB` lists both `vault_item` and `vault_share` tables AND the expected indexes exist
#### Scenario: applySchemaOnInit: false skips the step
- **WHEN** `createVaultPlugin({ storage, applySchemaOnInit: false })` initializes
- **THEN** the schema apply step is skipped
**Verified by:** `src/plugin/on-init.test.ts::"applies VAULT_OWNERSHIP_SCHEMA via migrationRunner"` AND `src/plugin/on-init.test.ts::"creates vault_item + vault_share tables"` AND `src/plugin/on-init.test.ts::"applySchemaOnInit: false skips apply"`
---
### Requirement: 5 routes registered with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)
`onInit` SHALL call `definePluginRoutes(buildVaultRoutes(vaultService, authService), { app, registry, pluginName: "vault" })` registering: `GET /vault/items`, `POST /vault/items`, `GET /vault/items/:id`, `PATCH /vault/items/:id`, `DELETE /vault/items/:id`. Every route uses `requireAuth()` upstream. Every body/query/response is a Zod schema from `./schemas/vault-item`. All routes scope to `c.get("user")!.id` — items from other users SHALL appear as `VAULT_PLUGIN_ITEM_NOT_FOUND` to prevent enumeration.
#### Scenario: list returns only the principal's items
- **WHEN** principal A owns 3 items and principal B owns 2; A is authenticated; GET /vault/items
- **THEN** the response contains 3 items, all `ownerId === A.id`
#### Scenario: GET /vault/items/:id of foreign item returns ITEM_NOT_FOUND
- **WHEN** principal A is authenticated and GETs an item owned by principal B
- **THEN** the response is 404 with `VAULT_PLUGIN_ITEM_NOT_FOUND` (NOT 403 — prevents enumeration)
#### Scenario: PATCH /vault/items/:id rejects unknown fields
- **WHEN** authenticated PATCH with `{ title: "X", evil: "Y" }`
- **THEN** the response is 400 from `UpdateVaultItemBodySchema` Zod strict-mode validation
#### Scenario: DELETE /vault/items/:id of foreign item returns FORBIDDEN
- **WHEN** principal A DELETEs an item that exists but is owned by B
- **THEN** the response is 404 (not 403, same anti-enumeration as GET)
**Verified by:** `src/routes/list.test.ts::"returns only principal's items"` AND `src/routes/get.test.ts::"foreign item returns ITEM_NOT_FOUND"` AND `src/routes/patch.test.ts::"rejects unknown fields"` AND `src/routes/delete.test.ts::"foreign item returns 404"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes 5 /vault/items routes"`
---
### Requirement: Item size enforced via maxItemSizeBytes
`POST /vault/items` and `PATCH /vault/items/:id` SHALL reject bodies whose encrypted payload exceeds `options.maxItemSizeBytes` (default 10 MiB) with `VaultPluginError(VAULT_PLUGIN_ITEM_TOO_LARGE)` (413).
#### Scenario: item over limit raises ITEM_TOO_LARGE
- **WHEN** `maxItemSizeBytes: 1024` and an authenticated POST sends a body larger than 1 KiB
- **THEN** the response is 413 with `VAULT_PLUGIN_ITEM_TOO_LARGE`
**Verified by:** `src/routes/create.test.ts::"oversize body raises ITEM_TOO_LARGE"`
---
### Requirement: vaultService registered with typed slot
`onInit` registers `ctx.registerService("vaultService", { openForPrincipal, listItemsForOwner, close })`. ServiceRegistry augmentation adds `vaultService: VaultService` with full type inference.
#### Scenario: openForPrincipal returns VaultHandle
- **WHEN** `vaultService.openForPrincipal(principal, password)` is awaited with a valid password
- **THEN** the result is a `VaultHandle` opaque type (no leak of internal vault state)
#### Scenario: bad password raises UNLOCK_FAILED
- **WHEN** `openForPrincipal` is called with the wrong password
- **THEN** `VaultPluginError(VAULT_PLUGIN_UNLOCK_FAILED)` is thrown
**Verified by:** `src/services/vault-service.test.ts::"openForPrincipal returns VaultHandle"` AND `src/services/vault-service.test.ts::"bad password raises UNLOCK_FAILED"` AND `src/services/vault-service.spec-conformance.test.ts::"ServiceRegistry slot typed as VaultService"`
---
### Requirement: Unlock password sourced via configurable extractor
The package SHALL accept `options.unlockExtractor?: (c: Context) => string | null` (default `headerExtractor("X-Vault-Unlock")`). When the extractor returns null on a route that requires unlock, the response SHALL be `VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING)` (400). The plugin SHALL NEVER log or store the extracted password.
#### Scenario: default extractor reads X-Vault-Unlock header
- **WHEN** an authenticated POST /vault/items includes `X-Vault-Unlock: hunter2`
- **THEN** the plugin uses `"hunter2"` to open the vault for the principal
#### Scenario: missing extractor result raises UNLOCK_HEADER_MISSING
- **WHEN** an authenticated POST /vault/items omits the unlock header
- **THEN** the response is 400 with `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`
#### Scenario: password never logged
- **WHEN** a logger captures every line emitted during a POST /vault/items run
- **THEN** none of the captured lines contain the extracted password substring
**Verified by:** `src/plugin/unlock.test.ts::"default extractor reads X-Vault-Unlock"` AND `src/plugin/unlock.test.ts::"missing header raises UNLOCK_HEADER_MISSING"` AND `src/plugin/unlock.test.ts::"password never logged"`
---
### Requirement: Zod schemas exported at ./schemas/* (plug-and-play point 2)
The package SHALL export schemas at `./schemas/vault-item` (`VaultItemSchema`, `VaultItemSummarySchema`, `VaultItemResponseSchema`, `VaultItemListResponseSchema`, `CreateVaultItemBodySchema`, `UpdateVaultItemBodySchema` strict mode) and `./schemas/options` (`VaultPluginOptionsSchema`). Every schema has a corresponding `z.infer` type exported alongside.
#### Scenario: UpdateVaultItemBodySchema is strict
- **WHEN** `UpdateVaultItemBodySchema.safeParse({ title: "X", evil: "Y" })` is called
- **THEN** `success === false`
#### Scenario: CreateVaultItemBodySchema requires title and kind
- **WHEN** `CreateVaultItemBodySchema.safeParse({})` is called
- **THEN** `success === false` (missing `title` and `kind`)
**Verified by:** `src/schemas/vault-item.test.ts::"UpdateVaultItemBodySchema is strict"` AND `src/schemas/vault-item.test.ts::"CreateVaultItemBodySchema requires title and kind"`
---
### Requirement: Every VAULT_PLUGIN_* code is thrown from a production path
`VAULT_PLUGIN_CODES` SHALL define exactly 9 codes (`VAULT_PLUGIN_INVALID_INPUT`, `VAULT_PLUGIN_ITEM_NOT_FOUND`, `VAULT_PLUGIN_FORBIDDEN`, `VAULT_PLUGIN_UNLOCK_FAILED`, `VAULT_PLUGIN_ITEM_TOO_LARGE`, `VAULT_PLUGIN_SERVICE_NOT_AVAILABLE`, `VAULT_PLUGIN_SCHEMA_APPLY_FAILED`, `VAULT_PLUGIN_ROUTE_PREFIX_COLLISION`, `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`). Every code SHALL be raised by ≥1 production call site.
**Verified by:** `src/api-plugin-vault.spec-conformance.test.ts::"every VAULT_PLUGIN_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry counters + histogram emit per operation
The package SHALL register counters `vault.items.created_total{outcome}`, `vault.items.read_total{outcome}`, `vault.items.updated_total{outcome}`, `vault.items.deleted_total{outcome}` and histogram `vault.item.size_bytes` (on create + update).
#### Scenario: created_total emits success
- **WHEN** a successful POST /vault/items runs against a real in-memory `MeterProvider`
- **THEN** `vault.items.created_total{outcome: "success"}` has 1 observation AND `vault.item.size_bytes` has 1 observation
**Verified by:** `src/otel/instruments.test.ts::"created_total emits success"` AND `src/otel/instruments.test.ts::"size_bytes histogram emits on create + update"`
---
### Requirement: Server-tier package shape + full integration test
Package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise the full plugin alongside `api-plugin-auth` + a real `@suaveplan/vault` instance with in-memory `DriveLike` against a SurrealDB testcontainer: sign-up via auth → POST /vault/items → GET /vault/items → PATCH → DELETE.
#### Scenario: full integration roundtrip
- **WHEN** `tests/integration/full-boot.test.ts` builds a real app with auth + this plugin, signs up, then POSTs/GETs/PATCHes/DELETEs a vault item with `X-Vault-Unlock` header
- **THEN** every step succeeds AND a subsequent GET /vault/items returns an empty list
**Verified by:** `tests/integration/full-boot.test.ts::"sign-up → POST → GET → PATCH → DELETE /vault/items roundtrip against real SurrealDB"` AND `tests/integration/openapi-doc.test.ts::"served doc lists 5 /vault/items routes"` AND `tests/integration/cross-user-isolation.test.ts::"user A cannot see / modify user B's items"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,211 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-webhook (add-api-plugin-webhook)
**Package**: `@suaveplan/api-plugin-webhook`
**Status**: ACCEPTED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: createWebhookPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createWebhookPlugin(options): PluginDescriptor<WebhookPluginConfig>` that validates `options` against `WebhookPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `WebhookError(WEBHOOK_INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "webhook"`, the resolved `defaults`, and `onInit` / `onClose` lifecycle hooks.
#### Scenario: factory produces a valid descriptor
- **WHEN** `createWebhookPlugin({})` is called with no overrides
- **THEN** the returned descriptor satisfies `PluginDescriptor<WebhookPluginConfig>` with `name === "webhook"`, `version === "0.1.0"`, and an `onInit` hook
#### Scenario: invalid options raise WEBHOOK_INVALID_INPUT
- **WHEN** `createWebhookPlugin({ maxAttempts: -1 } as never)` is called
- **THEN** `WebhookError(WEBHOOK_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createWebhookPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise WEBHOOK_INVALID_INPUT"`
---
### Requirement: onInit registers webhookService in ServiceRegistry
The descriptor's `onInit(ctx)` hook SHALL resolve `app` from `ctx`'s `ServiceRegistry`. If `app` is absent it SHALL throw `WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE)` listing `["app"]` in `context.missing`. When `app` is present, `onInit` SHALL construct the `WebhookService` (wiring the configured or default `EndpointStore`, `DeadLetterSink`, `idempotencyGuard`, `fetch`, `clock`, `logger`), register it under the `webhookService` slot via `ctx.registerService("webhookService", service)`, and mount the CRUD + test routes via `definePluginRoutes` (or directly on the Hono app if `openAPIRegistry` is absent).
#### Scenario: webhookService registered after onInit completes
- **WHEN** the plugin's `onInit` is invoked with a `ServiceRegistry` containing `app`
- **THEN** `ctx.getService("webhookService")` returns a `WebhookService` instance
#### Scenario: missing app raises WEBHOOK_SERVICE_NOT_AVAILABLE
- **WHEN** the plugin's `onInit` is invoked without `app` in the `ServiceRegistry`
- **THEN** `WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE)` is thrown with `context.missing` containing `"app"`
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers webhookService"` AND `src/plugin/plugin.test.ts::"onInit raises when app service is missing"`
---
### Requirement: onInit mounts CRUD + test routes under routePrefix
`onInit` SHALL mount six routes under `options.routePrefix` (default `/webhooks`) on the resolved Hono app, gated by `requireAuth()`: `GET /webhooks`, `POST /webhooks`, `GET /webhooks/:id`, `PATCH /webhooks/:id`, `DELETE /webhooks/:id`, `POST /webhooks/:id/test`. When `openAPIRegistry` is registered, the routes SHALL also be registered there via `definePluginRoutes` so the unified `/openapi.json` exposes them with Zod-typed request/response shapes. If another plugin already owns routes under `routePrefix`, `onInit` SHALL throw `WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION)`.
#### Scenario: routes mounted on Hono app
- **WHEN** the plugin's `onInit` completes
- **THEN** the Hono app responds (with a 401 from `requireAuth`) to `POST /webhooks` rather than 404
#### Scenario: routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION
- **WHEN** the `OpenAPIRegistry` already contains a route at the configured prefix and `onInit` runs
- **THEN** `WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix` set to the offending value
**Verified by:** `src/plugin/plugin.test.ts::"onInit mounts CRUD routes"` AND `src/plugin/plugin.test.ts::"routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION"`
---
### Requirement: WebhookService.send delivers a signed payload via webhook-client
`WebhookService.send(endpointId, event, data)` SHALL look up the endpoint via `EndpointStore.get`, raise `WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND)` if absent, raise `WebhookError(WEBHOOK_INACTIVE_ENDPOINT)` if `endpoint.active === false`, and otherwise build a `WebhookPayload` (`{ id, event, data, timestamp }`) and deliver it via `@suaveplan/webhook-client`'s `deliver()` using the endpoint's `secret`, `algorithm`, merged `headers`, and the configured `fetch`. The outbound request SHALL carry `Content-Type: application/json`, `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`, and the HMAC signature header (`X-Hub-Signature-256` or `-512`). The successful `DeliveryResult` SHALL be returned to the caller.
#### Scenario: successful single-attempt delivery
- **WHEN** an endpoint with secret `"shared"` is registered and the receiver returns 200 OK
- **THEN** `send()` resolves with `result.status === "delivered"`, `result.attempts.length === 1`, AND the receiver observed `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`, and a valid `X-Hub-Signature-256: sha256=<hex>` header
#### Scenario: missing endpoint raises WEBHOOK_ENDPOINT_NOT_FOUND
- **WHEN** `send("missing", "evt", {})` is called against an empty store
- **THEN** `WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND)` is thrown with `context.endpointId === "missing"`
#### Scenario: inactive endpoint raises WEBHOOK_INACTIVE_ENDPOINT
- **WHEN** the resolved endpoint has `active === false`
- **THEN** `WebhookError(WEBHOOK_INACTIVE_ENDPOINT)` is thrown without contacting the receiver
**Verified by:** `tests/integration/delivery.test.ts::"sends a signed payload with canonical headers"` AND `src/services/webhook-service.test.ts::"send raises WEBHOOK_ENDPOINT_NOT_FOUND when endpoint absent"` AND `src/services/webhook-service.test.ts::"send raises WEBHOOK_INACTIVE_ENDPOINT when endpoint inactive"`
---
### Requirement: Retry queue with exponential backoff and jitter
When the receiver returns a retryable response (HTTP 429 or 5xx, or a network error / timeout), `WebhookService.send` SHALL retry up to `maxAttempts - 1` additional times with exponential backoff: delay = `initial * 2^(attempt-1)`, capped at `maxRetryDelayMs`, with full jitter applied. 4xx responses other than 429 SHALL terminate immediately (no retry).
#### Scenario: receiver returns 500 twice then 200
- **WHEN** the receiver returns HTTP 500 on attempts 1 and 2 and HTTP 200 on attempt 3
- **THEN** `send()` resolves with `result.status === "delivered"`, `result.attempts.length === 3`, AND `result.finalStatusCode === 200`
#### Scenario: 4xx terminates without retry
- **WHEN** the receiver returns HTTP 422 on attempt 1
- **THEN** `send()` rejects with `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` AND only one attempt was made (no retries)
**Verified by:** `tests/integration/retry.test.ts::"retries 5xx then succeeds"` AND `tests/integration/retry.test.ts::"4xx terminates without retry"`
---
### Requirement: Dead-letter sink records exhausted deliveries
When `WebhookService.send` exhausts all `maxAttempts` retries, the service SHALL record a `DeadLetterEntry` (`{ id, endpointId, tenantId, event, payload, attempts, firstAttemptAt, finalAttemptAt, reason }`) via the configured `DeadLetterSink.record()` and SHALL raise `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` to the caller. If the sink itself throws, the service SHALL raise `WebhookError(WEBHOOK_DEAD_LETTER_WRITE_FAILED)` with the sink error as `cause` while preserving the original delivery failure on the chain.
#### Scenario: 5x 500 lands in the dead-letter sink
- **WHEN** the receiver returns HTTP 500 on all five attempts (`maxAttempts: 5`)
- **THEN** `send()` rejects with `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` AND the configured `DeadLetterSink.list({ endpointId })` contains a single entry with `attempts.length === 5`
#### Scenario: sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED
- **WHEN** the configured sink's `record()` rejects after a delivery is exhausted
- **THEN** `WebhookError(WEBHOOK_DEAD_LETTER_WRITE_FAILED)` is thrown with `cause` set to the sink error
**Verified by:** `tests/integration/dead-letter.test.ts::"5x 500 lands in dead letter"` AND `src/services/webhook-service.test.ts::"sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED"`
---
### Requirement: Per-delivery in-flight dedup
`WebhookService.send` SHALL dedup concurrent calls that share the same delivery key (`${endpointId}:${payload.id}`). When an `idempotencyGuard` is supplied, the call SHALL be routed through `guard.execute(key, () => delivery)` so cached results are returned for repeat calls within the dedup window. When no guard is supplied, the service SHALL maintain an in-process `Map<string, Promise<DeliveryResult>>` so concurrent calls with the same key resolve to the same delivery and the receiver only sees one request.
#### Scenario: concurrent sends with the same id resolve to one delivery
- **WHEN** two concurrent `send()` calls supply the same explicit payload id and the receiver counts requests
- **THEN** both promises resolve with `status === "delivered"` AND the receiver observed exactly one POST
**Verified by:** `src/services/webhook-service.test.ts::"in-flight dedup collapses concurrent sends with same id"`
---
### Requirement: Subscription CRUD routes are tenant-scoped and auth-gated
The six CRUD routes mounted under `routePrefix` SHALL be gated by `requireAuth()` so anonymous requests receive HTTP 401. When the request principal carries a `tenantId`, the routes SHALL only see endpoints whose `tenantId` matches the principal's; cross-tenant reads/writes SHALL be rejected with `WebhookError(WEBHOOK_TENANT_MISMATCH)`. When the principal carries no `tenantId` (admin / global scope), the routes SHALL operate on every endpoint regardless of tenant.
#### Scenario: anonymous request hits 401
- **WHEN** an unauthenticated request hits `GET /webhooks`
- **THEN** the response status is 401 AND the body carries `code === "WEBHOOK_UNAUTHENTICATED" | "AUTH_UNAUTHENTICATED"` (whichever auth gate is wired)
#### Scenario: cross-tenant read returns 404
- **WHEN** an authenticated request from tenant `"t-a"` hits `GET /webhooks/:id` for an endpoint owned by tenant `"t-b"`
- **THEN** the response status is 404 (the endpoint is hidden from the calling tenant)
**Verified by:** `src/routes/build-routes.test.ts::"anonymous request returns 401"` AND `src/routes/build-routes.test.ts::"cross-tenant read returns 404"`
---
### Requirement: POST /webhooks/:id/test fires a synthetic ping
`POST /webhooks/:id/test` SHALL look up the endpoint, raise `WEBHOOK_ENDPOINT_NOT_FOUND` if absent or tenant-mismatched, and otherwise call `service.test(endpointId)` which delivers a synthetic event `webhook.ping` with payload `{ requestedAt: ISO8601 }`. The response body SHALL be the `DeliveryResult` (status, finalStatusCode, attempts).
#### Scenario: ping is delivered and reported
- **WHEN** `POST /webhooks/:id/test` is called against a healthy endpoint backed by a 200-OK receiver
- **THEN** the response body has `status === "delivered"`, `finalStatusCode === 200`, AND the receiver observed `event === "webhook.ping"`
**Verified by:** `tests/integration/full-boot.test.ts::"POST /webhooks/:id/test fires a synthetic webhook.ping"`
---
### Requirement: OTel instruments emitted from every delivery path
The package SHALL register four OTel instruments via `getWebhookInstruments()`: counters `webhook_plugin.deliveries_total{event,status,endpoint_id}` and `webhook_plugin.dead_letters_total{event,endpoint_id}`; histograms `webhook_plugin.delivery_attempts{event,status}` and `webhook_plugin.send_duration_ms{status}`. Every `send()` call SHALL emit both counters / histograms regardless of outcome, with the `status` attribute taking one of `delivered | exhausted | not_found | inactive | invalid`.
#### Scenario: successful delivery records the counter
- **WHEN** a single successful delivery completes against a real in-memory `MeterProvider`
- **THEN** the collected metrics contain `webhook_plugin.deliveries_total` with `status: "delivered"` value `>= 1` AND `webhook_plugin.send_duration_ms` recorded at least one observation
#### Scenario: exhausted delivery records the dead-letter counter
- **WHEN** a delivery exhausts all retries
- **THEN** the collected metrics contain `webhook_plugin.dead_letters_total` with value `>= 1`
**Verified by:** `src/otel/instruments.test.ts::"deliveries_total increments on success"` AND `src/otel/instruments.test.ts::"dead_letters_total increments on exhaustion"`
---
### Requirement: Every WEBHOOK_* error code is reachable from a production path
The package SHALL define exactly ten error codes via `defineCodes("WEBHOOK", [...])`: `INVALID_INPUT`, `ENDPOINT_NOT_FOUND`, `TENANT_MISMATCH`, `EVENT_NOT_SUBSCRIBED`, `DELIVERY_EXHAUSTED`, `DEAD_LETTER_WRITE_FAILED`, `STORE_ERROR`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `INACTIVE_ENDPOINT`. Every code SHALL be thrown from at least one production code path in `src/` (asserted by the spec-conformance test).
#### Scenario: spec-conformance proves every code is thrown
- **WHEN** the `api-plugin-webhook.spec-conformance.test.ts` suite runs
- **THEN** the observed set of thrown `WEBHOOK_*` codes equals the declared set (10 of 10)
**Verified by:** `src/api-plugin-webhook.spec-conformance.test.ts::"every WEBHOOK_* code is thrown from a production path"`
---
### Requirement: Subpath exports are tree-shakeable
The package SHALL declare 14 subpath exports in `package.json` (`.`, `./plugin/plugin`, `./services/webhook-service`, `./store/endpoint-store`, `./store/memory`, `./dead-letter/dead-letter`, `./dead-letter/memory`, `./routes/routes`, `./schemas/options`, `./schemas/endpoint`, `./schemas/routes`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`). The package SHALL declare `"sideEffects": false`.
#### Scenario: every subpath resolves to a built file
- **WHEN** the dep-graph test enumerates `package.json` exports
- **THEN** every subpath maps to a `dist/<path>.js` entry and a sibling `.d.ts`
**Verified by:** `tests/dep-graph.test.ts::"declares 14 tree-shakeable subpath exports"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,181 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-plugin
**Package**: `@suaveplan/api-plugin`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: definePlugin factory produces a typed PluginDescriptor
The package SHALL export `definePlugin<TConfig>(descriptor): PluginDescriptor<TConfig>` that returns the descriptor with full `TConfig` type inference flowing through `configSchema?: ZodType<TConfig>`, `defaults?: Partial<TConfig>`, and the 4 lifecycle hooks (`onRegister/onInit/onReady/onClose`), each receiving a `PluginContext` typed for `TConfig`.
#### Scenario: descriptor preserves TConfig through the type system
- **WHEN** `definePlugin({ name, version, configSchema: MyConfigSchema, onInit: (ctx) => ctx.getOwnConfig() })` is invoked
- **THEN** `ctx.getOwnConfig()` returns `z.infer<typeof MyConfigSchema>` (no `unknown` widening)
**Verified by:** `src/define/define.test.ts::"definePlugin preserves TConfig type"` AND `src/define/define.spec-conformance.test.ts::"expectTypeOf getOwnConfig matches configSchema infer"`
---
### Requirement: PluginRegistry runs the 4-phase lifecycle in dep order
`PluginRegistry.initialize(context)` SHALL run topologically-sorted plugins through `onRegister → onInit → onReady` in that order, calling each hook on each plugin exactly once. `shutdown()` SHALL run `onClose` in **reverse** dep order. Each hook receives a `PluginContext` whose `signal: AbortSignal` is bound to the registry's `initSignal` / `shutdownSignal`.
#### Scenario: lifecycle hooks fire in dep order
- **WHEN** plugins `a` (no deps) and `b` (depends on `a`) register, and `initialize()` is awaited
- **THEN** `a.onRegister`, `a.onInit`, `a.onReady` complete before any of `b`'s lifecycle hooks
#### Scenario: shutdown reverses the order
- **WHEN** the same registry is shut down
- **THEN** `b.onClose` completes before `a.onClose`
**Verified by:** `src/registry/registry.test.ts::"initialize runs onRegister/onInit/onReady in dep order"` AND `src/registry/registry.test.ts::"shutdown calls onClose in reverse dep order"`
---
### Requirement: Topological sort uses Kahn's algorithm with deterministic tie-break
`topologicalSort(nodes)` SHALL implement Kahn's algorithm. Equal-in-degree nodes SHALL be processed in alphabetical order of `id` (stable across runs, independent of registration order). Missing required deps go into the `unresolved` array. Cycles raise `PluginError(PLUGIN_CIRCULAR_DEPENDENCY)` carrying the offending cycle path.
#### Scenario: equal-in-degree nodes sort alphabetically
- **WHEN** plugins `z`, `a`, `m` are registered in that order with no deps
- **THEN** `getSortedPlugins()` returns them in order `[a, m, z]` regardless of registration order
#### Scenario: cycle throws with cycle path
- **WHEN** plugins form a cycle `a → b → c → a` and `initialize()` is awaited
- **THEN** the promise rejects with `PluginError(PLUGIN_CIRCULAR_DEPENDENCY)` whose `context.cycle` equals `["a", "b", "c", "a"]`
**Verified by:** `src/topo-sort/topo-sort.test.ts::"sorts equal-in-degree nodes alphabetically"` AND `src/topo-sort/topo-sort.test.ts::"throws PLUGIN_CIRCULAR_DEPENDENCY with cycle path"`
---
### Requirement: PluginRegistry enforces initTimeout per hook
The registry SHALL accept `initTimeout` (default 30000ms) and `shutdownTimeout` (default 10000ms) in `PluginRegistryOptions`. If any single lifecycle hook on any plugin exceeds the per-hook budget, the registry SHALL reject with `PluginError(PLUGIN_INIT_TIMEOUT)` carrying `context.plugin` and `context.hook`. Timeout uses the injected `Clock` and is implemented via `AbortSignal` (the plugin's `ctx.signal` is aborted when the timer fires).
#### Scenario: hung onInit hits the timeout
- **WHEN** a plugin's `onInit` returns a Promise that never resolves and `initialize()` is awaited with `initTimeout: 100`
- **THEN** the promise rejects within ~100ms with `PluginError(PLUGIN_INIT_TIMEOUT)` whose `context.plugin` is the offending plugin name
**Verified by:** `src/registry/registry.test.ts::"initTimeout aborts a hung onInit"` AND `src/registry/registry.test.ts::"shutdownTimeout aborts a hung onClose"`
---
### Requirement: PluginContext.getService is type-safe via ServiceRegistry module augmentation
The package SHALL export `interface ServiceRegistry {}` (empty by default) from `./service-registry`. `registerService<K extends keyof ServiceRegistry>(name: K, service: ServiceRegistry[K])` and `getService<K extends keyof ServiceRegistry>(name: K): ServiceRegistry[K] | undefined` SHALL be typed against augmentations. Untyped string keys (not in `ServiceRegistry`) SHALL fail TypeScript compilation; runtime behaviour preserved for forward compatibility.
#### Scenario: augmented service slot types correctly
- **WHEN** a consumer declares `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { db: DbAdapter } }` and calls `ctx.getService("db")`
- **THEN** the return type is `DbAdapter | undefined` (no cast required)
#### Scenario: unknown service name fails compilation
- **WHEN** a consumer calls `ctx.getService("unknown-slot")` without augmenting `ServiceRegistry`
- **THEN** the compile fails
**Verified by:** `src/context/context.spec-conformance.test.ts::"expectTypeOf getService(K) returns ServiceRegistry[K] | undefined"` AND `src/context/context.spec-conformance.test.ts::"unknown service key produces a type error"`
---
### Requirement: HookExecutor handles non-lifecycle hooks only
The package SHALL ship `HookExecutor` with `registerHook(name, handler, pluginName, priority)`, `unregisterPlugin(name)`, `execute(name, ...args)` (sequential, abort on first throw → `PluginError(PLUGIN_HOOK_FAILED)`), and `executeAll(name, ...args)` (parallel via `Promise.allSettled`). Lifecycle hooks (`onRegister`/`onInit`/`onReady`/`onClose`) SHALL NOT be dispatched via the executor — registry owns them. The executor's hook namespace is for plugin-contributed custom hooks AND the request/tenant/db hooks that ship in sibling packages.
#### Scenario: registry does not invoke lifecycle hooks via executor
- **WHEN** a plugin's `onInit` is called by the registry and the executor is queried for `getHookCount("onInit")`
- **THEN** the count is 0 — lifecycle hooks are not registered with the executor
#### Scenario: custom hooks dispatch with priority
- **WHEN** two plugins register handlers for a custom hook `"my-extension"` with priorities 10 and 5, then `executor.execute("my-extension", ...args)` runs
- **THEN** the priority-10 handler runs first
**Verified by:** `src/hook-executor/hook-executor.test.ts::"lifecycle hooks are not registered with the executor"` AND `src/hook-executor/hook-executor.test.ts::"dispatch order respects priority"` AND `src/hook-executor/hook-executor.test.ts::"execute aborts on first throw with PLUGIN_HOOK_FAILED"`
---
### Requirement: Every PLUGIN_* error code is thrown from a production path
`PLUGIN_CODES` SHALL define exactly 10 codes (`PLUGIN_NOT_FOUND`, `PLUGIN_ALREADY_REGISTERED`, `PLUGIN_INIT_FAILED`, `PLUGIN_DEP_MISSING`, `PLUGIN_CIRCULAR_DEPENDENCY`, `PLUGIN_INIT_TIMEOUT`, `PLUGIN_SHUTDOWN_FAILED`, `PLUGIN_INVALID_CONFIG`, `PLUGIN_HOOK_FAILED`, `PLUGIN_ABORTED`). Every code SHALL be raised by at least one production call site.
#### Scenario: spec conformance exercises every code
- **WHEN** the conformance test exercises each code's production path
- **THEN** every code appears in the assertions
**Verified by:** `src/api-plugin.spec-conformance.test.ts::"every PLUGIN_* code is thrown from a production path"`
---
### Requirement: Universal-tier purity, subpath exports, sibling docs
The package SHALL run unchanged in Bun, Node, and the browser (no `node:fs`, no `process.cwd()` outside lazy CLI paths). `package.json` SHALL declare a subpath export per feature module. Every implementation `.ts` SHALL have a sibling `.md` of ≥200 words. No `export *` in feature barrels.
#### Scenario: dep-graph asserts package shape
- **WHEN** `tests/dep-graph.test.ts` inspects package.json `dependencies` + `exports`
- **THEN** dependencies are restricted to `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `tslib`, `zod`, `@opentelemetry/api`; every feature module is reachable via a declared subpath
**Verified by:** `tests/dep-graph.test.ts::"dependencies match the universal allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature module"` AND `tests/dep-graph.test.ts::"every implementation .ts has a sibling .md"`
---
### Requirement: 100% coverage with property test for topo-sort stability
The package SHALL enforce 100/100/100/100 statements/branches/functions/lines via Istanbul. `tests/property.test.ts` SHALL use fast-check (numRuns: 200) to assert that for arbitrary acyclic dep graphs `[id → deps]`, `topologicalSort` produces an order in which every node appears after every one of its declared dependencies AND the order is reproducible (calling sort twice on the same input yields identical output).
#### Scenario: dep-order invariant holds for arbitrary graphs
- **WHEN** fast-check generates random acyclic dep graphs and `topologicalSort` is called
- **THEN** for every node N in the result, every dep of N appears earlier in the result
**Verified by:** `tests/property.test.ts::"sorted order satisfies dep invariant"` AND `tests/property.test.ts::"sort is deterministic across repeated calls"`
---
### Requirement: PluginRegistry.seedServices bulk-registers entries before plugin init
The package SHALL add `PluginRegistry.seedServices<TMap extends Partial<{ [K in keyof ServiceRegistry]: ServiceRegistry[K] }>>(map: TMap): void`. Calling it stores every key/value pair in an internal seeded-services map. When `initialize()` later constructs per-plugin contexts, every seeded entry SHALL appear in `ctx.getService(name)` BEFORE any plugin's `onRegister` runs. Calling `seedServices` after `initialize()` has started SHALL raise `PluginError(PLUGIN_INVALID_STATE)`.
#### Scenario: seeded service is resolvable from the first plugin's onRegister
- **WHEN** `registry.seedServices({ app: stubApp })` is called, then a plugin with `onRegister(ctx) { ctx.getService("app") }` is registered, then `registry.initialize()` is awaited
- **THEN** the plugin's `onRegister` resolves `stubApp` (no `undefined`)
#### Scenario: later seed overrides earlier seed
- **WHEN** `registry.seedServices({ logger: l1 })` is followed by `registry.seedServices({ logger: l2 })` before `initialize()`
- **THEN** the next plugin's `ctx.getService("logger")` returns `l2`
#### Scenario: seedServices after initialize raises PLUGIN_INVALID_STATE
- **WHEN** `registry.initialize()` is awaited and then `registry.seedServices({ app: stubApp })` is called
- **THEN** `PluginError(PLUGIN_INVALID_STATE)` is thrown
**Verified by:** `src/registry/seed-services.test.ts::"seeded service resolvable from first plugin's onRegister"` AND `src/registry/seed-services.test.ts::"later seed overrides earlier"` AND `src/registry/seed-services.test.ts::"seedServices after initialize raises PLUGIN.INVALID_STATE"`
---
### Requirement: PluginRegistry exposes PLUGIN_INVALID_STATE in the code registry
`PLUGIN_CODES` SHALL include `PLUGIN_INVALID_STATE` (taking the registry to 11 codes total). The code SHALL be raised by `seedServices` post-`initialize()`; existing 10 codes are preserved.
#### Scenario: existing codes unchanged
- **WHEN** the spec-conformance test enumerates `PLUGIN_CODES`
- **THEN** the set is the union of the existing 10 codes plus `PLUGIN_INVALID_STATE` (no removals)
**Verified by:** `src/api-plugin.spec-conformance.test.ts::"PLUGIN_CODES contains PLUGIN_INVALID_STATE alongside the existing 10"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,88 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-rate-limit (api-rate-limit)
**Package**: `@suaveplan/api-rate-limit`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Token-bucket gate with route-declared rpm and burst
The package SHALL produce a `Middleware` enforcing a token bucket per key, parametrised by the route's `rateLimit.rpm` and `rateLimit.burst`. The bucket SHALL refill at `rpm/60` tokens per second up to `burst` tokens. Each request consumes one token; an empty bucket returns 429 with a `Retry-After` header equal to the refill ETA in seconds.
#### Scenario: burst window allows N requests
- WHEN a route declares `rpm: 60, burst: 5` and 5 requests arrive within 100ms for the same key
- THEN all 5 requests pass
#### Scenario: 6th request in burst window is rejected
- WHEN a 6th request arrives in the same 100ms window
- THEN the response is 429 with `Retry-After` set to a positive integer AND `api.rate_limit.exceeded_total{key_kind}` increments by 1
**Verified by:** `src/middleware/middleware.test.ts::"burst window allows N requests"` AND `src/middleware/middleware.test.ts::"sixth request in burst window is rejected with Retry-After"`
---
### Requirement: Refill is deterministic against a FakeClock
The package SHALL refill the bucket based on the supplied `Clock`. Advancing the clock by 1 second on an empty bucket with `rpm: 60` MUST restore exactly 1 token.
#### Scenario: clock advance refills tokens
- WHEN the bucket is empty and `clock.advance("1s")` runs
- THEN the next request consuming 1 token succeeds
**Verified by:** `src/middleware/middleware.test.ts::"clock advance refills tokens deterministically"`
---
### Requirement: Key selector resolves per route
The package SHALL resolve the rate-limit key from the route's `rateLimit.key` option. Selector kinds: `"tenant"`, `"principal"`, `"ip"`, and `{ kind: "custom", select }`. When the route does not specify a kind, the default is `"principal"` if a principal is resolved, else `"ip"`.
#### Scenario: tenant key isolates buckets
- WHEN two requests with different `tenantId` hit a route declaring `key: "tenant"`
- THEN each tenant has its own bucket AND neither affects the other
#### Scenario: default falls back to IP when no principal
- WHEN no principal is resolved AND the route does not specify a key
- THEN the IP address is used
**Verified by:** `src/selector/selector.test.ts::"tenant key isolates buckets"` AND `src/selector/selector.test.ts::"default falls back to IP without principal"`
---
### Requirement: Denial counter increments only on the denial transition
The package SHALL increment `api.rate_limit.exceeded_total` once per denied request — every denied request contributes one increment. The counter MUST be labelled with `plugin`, `route`, and `key_kind`.
#### Scenario: each denied request increments by exactly 1
- WHEN 10 requests are denied for the same key within the same window
- THEN the counter increases by exactly 10 AND each increment carries the correct label set
**Verified by:** `src/middleware/middleware.spec-conformance.test.ts::"denial increments api.rate_limit.exceeded_total exactly once per denied request"`
---
### Requirement: Host-level defaults override route declarations cleanly
The package SHALL accept default budgets from `host.json` (per-tenant default rpm/burst, per-IP default rpm/burst). Route-declared budgets MUST override the defaults. When a route declares no `rateLimit` and no host default applies, the middleware is a no-op.
#### Scenario: route override beats host default
- WHEN a host default is `rpm: 100` and a route declares `rpm: 10`
- THEN the route's limit (10) applies to that route
#### Scenario: no declaration, no host default → no-op
- WHEN neither route nor host declares a budget
- THEN the middleware passes the request through without checking any bucket
**Verified by:** `src/middleware/middleware.test.ts::"route override beats host default"` AND `src/middleware/middleware.test.ts::"no-op when neither route nor host declares"`

View File

@@ -0,0 +1,88 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-rbac
**Package**: `@suaveplan/api-rbac`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: Scope-based authorisation
The package SHALL produce a `Middleware` that, when the route's `auth` is a string scope name, permits the request only if `principal.scopes` contains that scope. Denials respond with HTTP 403 and the `RBAC_MISSING_SCOPE` error envelope.
#### Scenario: principal carries required scope
- WHEN a request with principal scopes `["billing:read"]` hits a route declaring `auth: "billing:read"`
- THEN the middleware permits the request
#### Scenario: principal missing required scope
- WHEN a request with principal scopes `["billing:read"]` hits a route declaring `auth: "billing:write"`
- THEN the response is 403 with `code === "RBAC_MISSING_SCOPE"` AND `api.rbac.denials_total{reason="missing-scope"}` increments
**Verified by:** `src/middleware/middleware.test.ts::"permits request with required scope"` AND `src/middleware/middleware.test.ts::"denies request missing required scope"` AND `src/middleware/middleware.spec-conformance.test.ts::"denial emits api.rbac.denials_total with missing-scope reason"`
---
### Requirement: Rule-based authorisation delegates to `@suaveplan/rules`
The package SHALL accept a `RouteAuthSpec` of kind `"rule"` and delegate evaluation to `@suaveplan/rules`. The middleware passes a context object containing `principal`, `tenant`, `request` (method/path/params/query), and `route` to the rule engine.
#### Scenario: rule evaluates to allow
- WHEN the rule evaluates true for the request context
- THEN the middleware permits the request
#### Scenario: rule evaluates to deny
- WHEN the rule evaluates false for the request context
- THEN the response is 403 with `code === "RBAC_RULE_DENY"` AND `api.rbac.denials_total{reason="rule-deny"}` increments
**Verified by:** `src/middleware/middleware.test.ts::"permits when rule evaluates true"` AND `src/middleware/middleware.test.ts::"denies when rule evaluates false"`
---
### Requirement: Missing principal on non-public route returns 401
The package SHALL distinguish "no principal" (401) from "principal lacks scope" (403). When the route is not declared public and no principal was resolved, the middleware returns 401 with `RBAC_NO_PRINCIPAL`.
#### Scenario: null principal on private route
- WHEN a request with `principal === null` hits a non-public route
- THEN the response is 401 with `code === "RBAC_NO_PRINCIPAL"` AND `api.rbac.denials_total{reason="no-principal"}` increments
**Verified by:** `src/middleware/middleware.test.ts::"returns 401 for null principal on non-public route"`
---
### Requirement: Plugin scopes auto-registered into a conflict-aware registry
The package SHALL expose `registerPluginScopes(registry, manifest)` that adds every scope from `manifest.scopes[]` to a shared `ScopeRegistry`. Two plugins declaring the same scope name raise `RBAC_SCOPE_CONFLICT`. Re-registering an identical descriptor for the same plugin is a no-op.
#### Scenario: duplicate scope across plugins raises conflict
- WHEN two manifests both declare `scopes = [{ name: "billing:read" }]`
- THEN the second registration throws `RBAC_SCOPE_CONFLICT`
#### Scenario: idempotent re-register for same plugin
- WHEN the same plugin manifest is registered twice (e.g. on reload)
- THEN the second call is a no-op
**Verified by:** `src/registry/registry.test.ts::"duplicate scope across plugins raises RBAC_SCOPE_CONFLICT"` AND `src/registry/registry.test.ts::"re-register for same plugin is idempotent"`
---
### Requirement: Public routes bypass the gate entirely
The package SHALL treat `RouteAuthSpec` of kind `"public"` as bypass — no scope check, no rule evaluation, no metric increment.
#### Scenario: public route always passes
- WHEN a request hits a route declaring `kind: "public"`
- THEN the middleware permits the request AND `api.rbac.denials_total` does not increment
**Verified by:** `src/middleware/middleware.test.ts::"public route bypass"` AND `src/middleware/middleware.spec-conformance.test.ts::"public route does not increment denial counter"`

View File

@@ -0,0 +1,109 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-router-hono
**Package**: `@suaveplan/api-router-hono`
**Status**: SHIPPED (0.1.0, 2026-05-26)
> Every requirement carries a `**Verified by:**` line.
## Requirement: Direct surface implements the abstract Router contract
The package SHALL export `createHonoRouter(opts?)` returning an instance of the abstract `Router` (from `@suaveplan/api-router`) backed by a private Hono instance. Every verb registration (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `all`) MUST mirror onto the underlying Hono app. The returned router MUST pass `runRouterConformance` from `@suaveplan/api-router`.
### Scenario: factory returns a Router-conformant adapter
- **WHEN** `createHonoRouter()` is called
- **THEN** the result is an instance of `HonoRouter` AND `runRouterConformance({ label: "hono", router: () => createHonoRouter(), slot: (p, h) => new HonoBlueGreenSlot(p, h) })` reports zero failures
### Scenario: every verb method registers a mirrored Hono route
- **WHEN** each of `r.get / post / put / patch / delete / head / options / all` is called
- **THEN** `r.match` returns a non-null `RouteMatch` for the registered method AND the same path resolves on the Hono app exposed via `r.getHonoApp()`
**Verified by:** `tests/conformance.test.ts` AND `src/adapter/adapter.test.ts::"registers every verb on both abstract router and Hono"` AND `src/adapter/adapter.test.ts::"Hono mirror stub returns 501 when dispatched"`
---
## Requirement: HonoBlueGreenSlot implements BlueGreenSlot
The package SHALL export `HonoBlueGreenSlot` implementing the `BlueGreenSlot` interface with atomic flip, in-flight counter, and AbortSignal-aware `dispose(timeoutMs)`. `flip()` with no shadow MUST throw `ROUTER.SLOT_FLIP_FAILED`. `dispose()` timing out MUST throw `ROUTER.SLOT_DISPOSE_TIMEOUT` AND abort the slot's in-flight requests.
### Scenario: flip + dispose drains in-flight work
- **WHEN** an acquired live slot is flipped, then released after the timer starts
- **THEN** `dispose` resolves successfully without firing the timeout
### Scenario: dispose times out and aborts
- **WHEN** `dispose(slotId, { timeoutMs: 20 })` is called against a slot with pending work that is never released
- **THEN** the call rejects with `ROUTER.SLOT_DISPOSE_TIMEOUT` AND `signalFor(slotId).aborted === true`
**Verified by:** `src/blue-green-slot/blue-green-slot.test.ts::"dispose waits for release"` AND `src/blue-green-slot/blue-green-slot.test.ts::"dispose times out and aborts pending"`
---
## Requirement: Middleware ⇄ Hono MiddlewareHandler bridge
The package SHALL export `toHonoMiddleware(mw)` adapting a genesis `Middleware` onto Hono, and `fromHonoMiddleware(handler)` for the reverse direction. `toHonoMiddleware` MUST reflect the inbound Hono context as a `RouterRequest` (headers, query, params) and serialise the outbound `RouterResponse` to a Web `Response`. Errors thrown inside the middleware MUST be wrapped in `RouterHonoError` with code `ROUTER_HONO.HONO_INTERNAL_ERROR`, preserving any `Error` cause.
### Scenario: middleware participates in Hono dispatch
- **WHEN** `app.use("*", toHonoMiddleware(mw))` is installed and a request flows through
- **THEN** `mw` observes the canonical `RouterRequest` shape AND the response status / headers / body land on the wire
### Scenario: thrown errors get wrapped
- **WHEN** the genesis middleware throws a plain `Error`
- **THEN** Hono `onError` observes a `RouterHonoError` with `code === "ROUTER_HONO.HONO_INTERNAL_ERROR"` AND `cause === thrown`
### Scenario: invalid peer rejected
- **WHEN** `fromHonoMiddleware(value)` is called with a non-function
- **THEN** it throws `RouterHonoError` with `code === "ROUTER_HONO.INVALID_HONO_PEER"`
**Verified by:** `src/middleware-adapter/middleware-adapter.test.ts::"toHonoMiddleware adapts genesis Middleware onto Hono"` AND `src/middleware-adapter/middleware-adapter.test.ts::"toHonoMiddleware wraps thrown errors"` AND `src/middleware-adapter/middleware-adapter.test.ts::"fromHonoMiddleware rejects non-function peer"`
---
## Requirement: ROUTER_HONO error-code registry
The package SHALL expose `ROUTER_HONO_CODES` (`defineCodes("ROUTER_HONO", [...])`) and a `RouterHonoError` extending `SuaveplanError`. The registry MUST contain `HONO_INTERNAL_ERROR`, `INVALID_HONO_PEER`, AND `PIPELINE_ROUTE_COLLISION`. Every code MUST be thrown by at least one production path in this package.
### Scenario: registry shape
- **WHEN** `Object.keys(ROUTER_HONO_CODES)` is inspected
- **THEN** the keys are exactly `["HONO_INTERNAL_ERROR", "INVALID_HONO_PEER", "PIPELINE_ROUTE_COLLISION"]` AND each value is `ROUTER_HONO.<name>`
**Verified by:** `src/error-codes/error-codes.test.ts::"enumerates the three declared codes"` AND `src/error-codes/error-codes.test.ts::"namespaces each entry under ROUTER_HONO.*"`
---
## Requirement: HonoRouter pipeline facet satisfies the host's PipelineRouter contract
The package SHALL extend `HonoRouter` with a second, additive facet that produces values structurally assignable to `@suaveplan/api-host`'s `PipelineRoute` interface. The facet SHALL expose:
- `registerPipelineRoute(reg: HonoPipelineRegistration): void` — stores `(plugin, method, path, handler, options?, statusMap?)`.
- `unregisterPipelineRoute(method: string, path: string): void` — no-op when absent.
- `unregisterPipelinePlugin(plugin: string): void` — removes every entry owned by `plugin` (hot-reload teardown).
- `listPipelineRoutes(): readonly HonoPipelineRoute[]` — snapshot for admin tooling.
- `matchPipeline(method: string, url: string): HonoPipelineRoute | null` — resolves `(method, url)` to a `HonoPipelineRoute` carrying `plugin`, `path`, `options`, `handler`, `params`, `statusMap`.
The facet SHALL preserve the existing abstract `Router` surface verbatim. Registering the same `(method, path-shape)` under a DIFFERENT plugin SHALL throw `ROUTER_HONO.PIPELINE_ROUTE_COLLISION`. Re-registering the same `(plugin, method, path)` SHALL overwrite. Parameter names SHALL be erased in the collision key so `:id` and `:other` clash.
### Scenario: facet satisfies the host's PipelineRouter conformance suite
- **WHEN** `runPipelineRouterConformance` (from `@suaveplan/api-host/testing`) is invoked with a factory that returns `{ router: { match: (m, u) => r.matchPipeline(m, u) }, register: (reg) => r.registerPipelineRoute(reg) }`
- **THEN** every harness case passes (register → match round-trip, 404, statusMap propagation, plugin ownership across reload, param parsing, collision detection)
### Scenario: collision detection throws ROUTER_HONO.PIPELINE_ROUTE_COLLISION
- **WHEN** two plugins register the same `(method, path-shape)` on one `HonoRouter`
- **THEN** the second call throws `RouterHonoError` with `code === "ROUTER_HONO.PIPELINE_ROUTE_COLLISION"`
### Scenario: hot-reload teardown removes plugin-owned entries
- **WHEN** `unregisterPipelinePlugin("billing")` is called after `billing` registered two pipeline routes
- **THEN** subsequent `matchPipeline(...)` calls against either route return `null` AND `listPipelineRoutes()` no longer contains a `"billing"`-owned entry
**Verified by:** `src/pipeline/pipeline.test.ts` AND `src/pipeline/pipeline.conformance.test.ts` AND `src/adapter/adapter.test.ts::"pipeline facet — register/match/list/unregister/unregisterPlugin"`

View File

@@ -0,0 +1,83 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-router-in-memory
**Package**: `@suaveplan/api-router-in-memory`
**Status**: SHIPPED
## Requirements
### Requirement: Dispatch without a socket
The package SHALL implement `Router` such that match resolution runs the full request pipeline in-process and returns route data without binding any socket, port, or HTTP listener.
#### Scenario: match resolves without network I/O
- **WHEN** a route is registered AND `router.match("GET", "/x")` is called
- **THEN** the result is a route match AND no network socket is opened
**Verified by:** `src/adapter/adapter.test.ts::"dispatch sanity — no network sockets opened by match"`
---
### Requirement: Implements both Router and PipelineRouter contracts
`InMemoryRouter` SHALL satisfy both the abstract `Router` contract from `@suaveplan/api-router` (verb registration, mount, collision detection, param parsing) AND the `PipelineRouter` contract from `@suaveplan/api-host` (`matchPipeline` returning a structurally compatible `PipelineRoute`).
#### Scenario: every Router invariant passes
- **WHEN** `runRouterConformance({ label: "in-memory", router, slot })` runs
- **THEN** every invariant reports passing
#### Scenario: every PipelineRouter invariant passes
- **WHEN** `runPipelineRouterConformance({ label: "...", build })` runs against `matchPipeline`
- **THEN** every invariant reports passing
**Verified by:** `tests/conformance/api-router.test.ts` AND `tests/conformance/api-host.test.ts`
---
### Requirement: Pipeline facet supports hot-reload teardown
`InMemoryRouter` SHALL expose `registerPipelineRoute`, `unregisterPipelineRoute`, `unregisterPipelinePlugin`, `listPipelineRoutes`, and `matchPipeline`. `unregisterPipelinePlugin(plugin)` SHALL remove every route owned by `plugin` in one call. Same-plugin re-registration SHALL overwrite; cross-plugin collision SHALL throw `ROUTER_IN_MEMORY.ROUTE_COLLISION`.
#### Scenario: register/match/list/unregister/unregisterPlugin
- **WHEN** routes for plugins `a` and `b` are registered and `unregisterPipelinePlugin("a")` is called
- **THEN** plugin `b`'s entries remain matchable and plugin `a`'s entries return null
#### Scenario: cross-plugin collision throws ROUTE_COLLISION
- **WHEN** plugin `b` attempts to register a method+path-shape already owned by plugin `a`
- **THEN** `RouterInMemoryError` with code `ROUTER_IN_MEMORY.ROUTE_COLLISION` is thrown
**Verified by:** `src/adapter/adapter.test.ts::"pipeline facet — register/match/list/unregister/unregisterPlugin"` AND `src/pipeline/pipeline.test.ts::"cross-plugin collision throws ROUTE_COLLISION"`
---
### Requirement: Options validated at construction boundary
`InMemoryRouter` SHALL validate constructor options with a Zod schema. Invalid options SHALL throw `RouterInMemoryError` with code `ROUTER_IN_MEMORY.INVALID_PREFIX` carrying the offending value in `context`.
#### Scenario: invalid plugin option throws INVALID_PREFIX
- **WHEN** `new InMemoryRouter({ plugin: "bad/plugin" })` is called
- **THEN** `RouterInMemoryError(ROUTER_IN_MEMORY.INVALID_PREFIX, ...)` is thrown
**Verified by:** `src/adapter/adapter.test.ts::"invalid plugin option throws INVALID_PREFIX"`
---
### Requirement: Test harness for downstream consumers
The package SHALL expose `createInMemoryRouterHarness()` returning a fresh `InMemoryRouter` and a pinned `InMemoryBlueGreenSlot` factory, replacing the hand-rolled router shims across foundation + plugin tests.
#### Scenario: harness returns a fresh router + slot pair
- **WHEN** `createInMemoryRouterHarness()` is called twice
- **THEN** each call yields a distinct router AND distinct slot instances
**Verified by:** `src/testing/testing.test.ts::"slot factory produces fresh InMemoryBlueGreenSlot instances"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,106 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-router-worker-bridge (api-router-worker-bridge)
**Package**: `@suaveplan/api-router-worker-bridge`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## Requirements
### Requirement: Raw Request object never crosses the worker boundary
The package SHALL marshal only the already-validated `body`, `params`, `query`, plus a filtered `headers` map, the resolved `principal`, `tenantId`, `requestId`, and W3C trace context across the worker port. The raw `Request` object MUST NOT appear in the wire envelope under any circumstance. Worker-side reconstruction SHALL produce a `ctx.request` shape that exposes ONLY these fields — there is no way for worker code to read the original socket, the original `Headers` instance, or any unfiltered header.
#### Scenario: envelope wire shape contains no Request reference
- **WHEN** an `HttpDispatchEnvelope` is serialised
- **THEN** the structured-clone payload contains no `Request` instance AND `JSON.stringify` of the envelope succeeds (proving plain-data shape)
#### Scenario: worker ctx.request lacks underlying Request access
- **WHEN** a handler running under the bridge inspects `ctx.request`
- **THEN** the available keys are exactly `["body", "params", "query", "headers", "method", "url"]` AND no `Request` prototype is reachable
**Verified by:** `tests/no-raw-request.test.ts::"envelope contains no Request reference"` AND `src/worker-side/worker-side.test.ts::"ctx.request exposes only marshalled fields"`
---
### Requirement: Default header allowlist denies credentials-bearing headers
The package SHALL apply a default deny list to outgoing envelopes that includes `authorization`, `cookie`, `set-cookie`, `x-csrf-token`, and `proxy-authorization`. Headers on the deny list MUST NOT appear in `envelope.headers` regardless of route configuration. Routes MAY EXTEND the allowlist via configuration to include arbitrary headers but MUST NOT REMOVE any default-denied header from the deny list.
#### Scenario: Authorization is stripped from envelope by default
- **WHEN** a route hit carries `Authorization: Bearer abc`
- **THEN** the resulting envelope's `headers` map has no `authorization` key
#### Scenario: Cookie cannot be allowlisted via config
- **WHEN** a route attempts to allowlist `cookie`
- **THEN** the bridge throws `BRIDGE_HEADER_NOT_ALLOWED` at configuration time
**Verified by:** `src/header-allowlist/header-allowlist.spec-conformance.test.ts::"Authorization stripped by default"` AND `src/header-allowlist/header-allowlist.spec-conformance.test.ts::"cookie cannot be allowlisted"`
---
### Requirement: Envelope schemas are Zod-validated on both sides
The package SHALL define Zod schemas for `HttpDispatchEnvelope` and `HttpResponseEnvelope`. Main-side `dispatch()` MUST validate before sending. Worker-side `serve()` MUST validate on receipt. Validation failure on either side MUST throw `BRIDGE_ENVELOPE_INVALID` with the Zod issue tree in `details`.
#### Scenario: malformed envelope is rejected
- **WHEN** a non-object value is sent across the port
- **THEN** the receiving side throws `BRIDGE_ENVELOPE_INVALID` AND the request promise rejects
**Verified by:** `src/envelope-schemas/envelope-schemas.test.ts::"malformed envelope throws BRIDGE_ENVELOPE_INVALID"`
---
### Requirement: Request and response streams transfer via Transferable
The package SHALL marshal request-body `ReadableStream` and response-body `ReadableStream` via `Transferable` so chunks transfer zero-copy. Back-pressure SHALL be propagated end-to-end via `api-worker-rpc`'s streaming ack protocol — the consumer's slow read MUST throttle the producer.
#### Scenario: 16 MiB request stream round-trips
- **WHEN** the main side dispatches with a 16 MiB request body stream
- **THEN** the worker handler observes the full byte sequence AND `transferList` was used (verified by absence of allocation in main process after transfer)
#### Scenario: response stream backpressure throttles producer
- **WHEN** the main side reads slowly from a worker-produced response stream
- **THEN** the worker-side producer stalls on missing acks
**Verified by:** `src/streaming-marshal/streaming-marshal.test.ts::"16 MiB request stream round-trips"` AND `src/streaming-marshal/streaming-marshal.test.ts::"response stream backpressure throttles producer"`
---
### Requirement: Bridge passes the router conformance suite under the worker-proxy surface
The package SHALL be runnable as the surface argument to `runRouterConformance` from `@suaveplan/api-router/testing`. Every router invariant (route match, param extraction, middleware order, error propagation, etc.) MUST hold across the bridge identically to a direct in-process router. This is the Stage-3 gate that lets Stage 4 declare router conformance "both surfaces".
#### Scenario: router conformance suite passes against bridge
- **WHEN** `runRouterConformance("worker-bridge", makeBridgeFactory)` runs
- **THEN** every conformance scenario passes
**Verified by:** `tests/conformance/router-conformance.test.ts::"runRouterConformance passes against bridge"`
---
### Requirement: Handler throw propagates as SuaveplanError without leaking handler internals
The package SHALL forward worker-side handler throws back across the bridge via `api-worker-rpc`'s error-reconstruction. `SuaveplanError` instances MUST round-trip with code + cause. Plain `Error` throws MUST be wrapped in `SuaveplanError` with code `BRIDGE_HANDLER_THROW`. The wire envelope MUST NOT carry the raw `Error.stack` as a live property — only as a `details.remoteStack` string.
#### Scenario: SuaveplanError round-trips with code preserved
- **WHEN** a worker-side handler throws `SuaveplanError("PLUGIN.X.NOT_FOUND")`
- **THEN** the main side receives a `SuaveplanError` with code `PLUGIN.X.NOT_FOUND`
#### Scenario: plain Error wraps as BRIDGE_HANDLER_THROW
- **WHEN** a worker-side handler throws `new RangeError("boom")`
- **THEN** the main side receives `SuaveplanError` with code `BRIDGE_HANDLER_THROW` AND `details.remoteName === "RangeError"`
**Verified by:** `src/worker-side/worker-side.test.ts::"SuaveplanError round-trips with code"` AND `src/worker-side/worker-side.test.ts::"plain Error wraps as BRIDGE_HANDLER_THROW"`

View File

@@ -0,0 +1,186 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-router
**Package**: `@suaveplan/api-router`
**Status**: SHIPPED (Stage-1 contract, 0.1.0)
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: Abstract Router interface
The package SHALL export the `Router` interface with verb methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `all`), a `use(middleware: Middleware)` chain-appender, and a `mount(prefix: string, sub: Router)` composition method. Each verb method MUST accept `(path, options: RouteOptions, handler: RouteHandler)` AND `(path, handler)` (options optional). The interface MUST be generic over `Caps extends CapabilityMap` so handlers receive a typed `RequestContext<Caps>`.
#### Scenario: every verb method is declared
- **WHEN** `expectTypeOf<Router>()` is checked
- **THEN** every HTTP verb is callable with both 2-arg and 3-arg forms
#### Scenario: mount prefixes child routes
- **WHEN** a parent router `r` mounts child router `c` at prefix `/billing`, AND `c` declares `GET /invoices`
- **THEN** the parent's `match("GET", "/billing/invoices")` returns the child's handler
**Verified by:** `src/router/router.spec-conformance.test.ts::"Router declares every verb method with overloaded signatures"` AND `src/router/router.test.ts::"mount prefixes child routes"`
---
### Requirement: `import z from "zod"` everywhere
Every `.ts` file using Zod MUST use the default-import form. The named-import form (`import z from "zod"`) is forbidden.
#### Scenario: no named import of `z`
- **WHEN** the test greps every `src/**/*.ts`
- **THEN** zero matches for `import z` from `"zod"`
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: RouteOptions schema covers the design-doc keys
The package SHALL export `routeOptionsSchema: z.ZodSchema<RouteOptions>` whose inferred type is the concrete-adapter-superset of `DefaultRouteOptions` from `@suaveplan/api-types/router`. The schema MUST include: `auth?: string`, `summary?: string`, `tags?: readonly string[]`, `request?: { body?, params?, query? }` (each a `z.ZodSchema`), `responses?: Record<number, { body: z.ZodSchema }>`, `runtimeValidation` (default `true`), `openapi` (default `true`), `streaming` (default `false`), `csrf?: boolean`, `idempotent?: boolean`, `rateLimit?: { rpm: number } | { rps: number }`. The local `RouteOptions` SHALL be structurally compatible with `DefaultRouteOptions` from api-types — an object shaped against api-types alone MUST parse successfully via `routeOptionsSchema.safeParse`. The local `RouteOptions` is the concrete `Options` type parameter this package supplies to `Router<Caps, Options>` from api-types.
#### Scenario: defaults apply when fields are absent
- **WHEN** `routeOptionsSchema.parse({})` is called
- **THEN** `runtimeValidation === true` AND `openapi === true` AND `streaming === false`
#### Scenario: api-types-shaped options parse via the local schema
- **WHEN** an object shaped using only the api-types `DefaultRouteOptions` keys (e.g. `{ auth: "users.read", summary: "List users" }`) is passed to `routeOptionsSchema.safeParse`
- **THEN** `success` is `true`
#### Scenario: unknown option keys rejected
- **WHEN** `routeOptionsSchema.parse({ badKey: 1 })` is called
- **THEN** Zod reports an issue with path `["badKey"]`
**Verified by:** `src/route-options/route-options.test.ts::"defaults apply when fields absent"` AND `src/route-options/route-options.spec-conformance.test.ts::"rejects unknown option keys"` AND `src/route-options/route-options.spec-conformance.test.ts::"api-types-shaped objects parse successfully"`
---
### Requirement: Middleware composition with insertion-point guard
The package SHALL export `composeMiddleware(list: readonly Middleware[]): Middleware` returning a single middleware that runs each entry in order, threading `next()` correctly. Plugin-supplied middleware MUST only be insertable at the steps-7-8 boundary of the per-request pipeline (design §6). Insertion at an earlier step MUST throw `ROUTER.MIDDLEWARE_INSERTION_DENIED`.
#### Scenario: chain runs in order
- **WHEN** three middlewares logging "A", "B", "C" are composed AND a request flows through
- **THEN** logs appear in order A, B, C, response, C, B, A (wrap-and-unwrap)
#### Scenario: insertion before auth rejected
- **WHEN** plugin code calls `router.insertMiddleware({ step: "pre-auth", mw })`
- **THEN** the call throws `ROUTER.MIDDLEWARE_INSERTION_DENIED`
**Verified by:** `src/middleware/compose-middleware.test.ts::"chain runs in declared order"` AND `src/middleware/insertion-point.test.ts::"plugin middleware rejected before auth step"`
---
### Requirement: BlueGreenSlot interface
The package SHALL export `BlueGreenSlot` representing a per-plugin mount point with three slot positions: `live` (currently serving), `shadow` (newly imported, not yet serving), `draining` (previous `live`, finishing in-flight requests). The interface MUST expose: `setShadow(handler)`, `flip(): DrainingHandle` (atomic — returns the old live slot as draining), `dispose(slot, opts: { timeoutMs })`. A flip on a slot with no shadow MUST throw `ROUTER.SLOT_FLIP_FAILED`. A `dispose` whose timeout fires before in-flight requests settle MUST throw `ROUTER.SLOT_DISPOSE_TIMEOUT` AND the abort signal MUST be wired on the still-pending requests.
#### Scenario: flip is atomic
- **WHEN** `setShadow(N+1)` is called AND `flip()` is called
- **THEN** the returned draining handle references slot N AND subsequent matches route to N+1
#### Scenario: flip with no shadow rejected
- **WHEN** `flip()` is called with `shadow === null`
- **THEN** `ROUTER.SLOT_FLIP_FAILED` is thrown
**Verified by:** `src/slot/slot.test.ts::"flip atomically promotes shadow and returns draining handle"` AND `src/slot/slot.test.ts::"flip with no shadow throws SLOT_FLIP_FAILED"`
---
### Requirement: Route collision detection
The package SHALL export `Router.match(method, url): RouteMatch | null` and `Router.collidesWith(method, path): RouteMatch[]`. Registering a route whose method + path collides with an existing route on the same plugin MUST throw `ROUTER.ROUTE_COLLISION`. Cross-plugin collisions are NOT this package's responsibility — they're caught by the host loader against the manifests.
#### Scenario: same-plugin collision rejected
- **WHEN** `router.get("/x", h1)` is called twice on the same router
- **THEN** the second call throws `ROUTER.ROUTE_COLLISION`
**Verified by:** `src/router/router.test.ts::"same-plugin route collision rejected"`
---
### Requirement: Error codes registry frozen
The package SHALL export `ROUTER_CODES` via `defineCodes` containing exactly: `ROUTE_COLLISION`, `INVALID_PREFIX`, `MIDDLEWARE_INSERTION_DENIED`, `SLOT_FLIP_FAILED`, `SLOT_DISPOSE_TIMEOUT`. Every code MUST be thrown by a production path.
#### Scenario: every code reachable
- **WHEN** the test enumerates `ROUTER_CODES`
- **THEN** every code has a production-path throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every ROUTER_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for adapters
The package SHALL export `runRouterConformance({ factory, label })` from `./testing`. The harness MUST exercise: every verb method, mount, middleware chain, route collision, slot flip / drain / dispose, request param parsing, error propagation. Every adapter in Stages 4-7 (`api-router-hono`, `api-router-in-memory`, the worker bridge) MUST pass this harness.
#### Scenario: harness exercises every required case
- **WHEN** the harness is run against a stub Router built from the abstract interface
- **THEN** at least one `it(...)` exists for verbs, mount, middleware, collision, slot lifecycle
**Verified by:** `src/testing/conformance.test.ts::"harness covers verbs, mount, middleware, collision, slot lifecycle"`
---
### Requirement: Subpath exports
Every category (`router`, `request`, `response`, `route-options`, `middleware`, `route-match`, `slot`, `error-codes`, `testing`) SHALL be reachable via its own subpath export. Root barrel uses explicit named re-exports (rule 10).
#### Scenario: every documented subpath resolves
- **WHEN** the test imports each subpath
- **THEN** every import resolves
**Verified by:** `tests/subpaths.test.ts::"every documented subpath is importable"` AND `tests/dep-graph.test.ts::"root barrel has no export-star"`
---
### Requirement: Ships the canonical `runPipelineRouterConformance` harness
`@suaveplan/api-router` SHALL export `runPipelineRouterConformance({ label, build })` from `./testing/pipeline-router-conformance` (with an ergonomic re-export through `./testing`). The harness exercises the `PipelineRouter` contract owned by `@suaveplan/api-types/pipeline-router` and proves every concrete adapter (`api-router-in-memory`, the `HonoRouter` pipeline facet in `api-router-hono`, future Stage-7 engines) satisfies the §6 request-pipeline router seam. `@suaveplan/api-host` re-exports the harness from its `./testing` subpath so existing consumers continue to import the same shape without code changes.
#### Scenario: harness invariants unchanged from the api-host source location
- **WHEN** an adapter that previously passed `runPipelineRouterConformance` from `@suaveplan/api-host/testing` re-targets the harness at `@suaveplan/api-router/testing/pipeline-router-conformance`
- **THEN** the same scenarios pass with no signature or behaviour change
#### Scenario: ergonomic `./testing` re-export
- **WHEN** `import { runPipelineRouterConformance } from "@suaveplan/api-router/testing"` is used
- **THEN** the symbol resolves AND matches the dedicated-subpath export by identity
**Verified by:** `src/testing/pipeline-router-conformance/pipeline-router-conformance.test.ts` (self-test against ReferencePipelineRouter) AND the `api-router-in-memory` + `api-router-hono` conformance suites.
---
### Requirement: Cycle-free build graph after PipelineRouter harness relocation
After the `api-router-pipeline-extraction` change, the workspace build graph MUST NOT contain a cycle between `@suaveplan/api-host` and `@suaveplan/api-router-in-memory`. Turborepo's `bunx turbo run build` MUST complete without emitting `Cyclic dependency detected`.
#### Scenario: turbo schedules build with no cycle warning
- **WHEN** `bunx turbo run build --filter=@suaveplan/api-host` is run from a clean tree
- **THEN** stderr contains no `Cyclic dependency detected` line AND the task graph builds successfully
#### Scenario: repo-wide test gate runs end-to-end
- **WHEN** `bunx turbo test --output-logs=errors-only` is run
- **THEN** the exit code is 0 AND no package reports a cycle warning
**Verified by:** `bunx turbo run build --dry=json --filter=@suaveplan/api-host | jq` shows no `api-router-in-memory#build` edge AND `bunx turbo run build --dry=json --filter=@suaveplan/api-router-in-memory | jq` shows no `api-host#build` edge.

View File

@@ -0,0 +1,142 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-saga
**Package**: `@suaveplan/api-saga`
**Status**: SHIPPED (Stage 6.1, 2026-05-26)
> Every requirement below carries a `**Verified by:**` line. (See `openspec/AGENTS.md` Section 13.)
## Requirements
### Requirement: Saga ownership versus participation
The package SHALL distinguish saga *owners* (plugins whose manifest declares `sagas.owns[]`) from saga *participants* (plugins whose manifest declares `sagas.participatesIn[]` AND export named `actions` / `compensations`). At plugin install, the orchestrator MUST validate that for every owned saga's declared steps the named target plugin exists, exports the named action, and — where the step declares a `compensate` — exports the named compensation. Two plugins declaring `owns` of the same saga name MUST cause install to fail with `SAGA_DUPLICATE_OWNER`.
#### Scenario: owner registers a saga its participants can join
- **WHEN** plugin A's manifest declares `sagas.owns: [{ name: "X", steps: [{ plugin: "B", action: "a", compensate: "c" }] }]` AND plugin B's manifest declares `sagas.participatesIn: ["A/X"]` AND plugin B exports `actions.a` and `compensations.c`
- **THEN** the participant registry validates successfully AND `orchestrator.start("X", input, ctx)` is callable
#### Scenario: duplicate owner is rejected at install
- **WHEN** plugin A and plugin C both declare `owns: [{ name: "X", ... }]`
- **THEN** install fails with `SAGA_DUPLICATE_OWNER`
#### Scenario: missing participant action is rejected at install
- **WHEN** the saga step names `actions.a` on plugin B AND plugin B does not export `actions.a`
- **THEN** install fails with `SAGA_UNKNOWN_PARTICIPANT`
**Verified by:** `src/registry/participant-registry.test.ts::"validates owner + participant wiring"` AND `src/registry/participant-registry.test.ts::"rejects duplicate saga owner with SAGA_DUPLICATE_OWNER"` AND `src/registry/participant-registry.test.ts::"rejects missing participant action with SAGA_UNKNOWN_PARTICIPANT"`
---
### Requirement: Step dispatch via event bus with idempotent execution
The orchestrator SHALL drive steps in declared order by emitting `saga.step.requested` on the host event bus with `{sagaId, stepName, input}`. The target plugin's `actions[stepName]` handler runs under a `RequestContext` whose `principal` is the saga initiator and whose `tenantId` is the saga's target tenant. Every step invocation MUST be wrapped in `@suaveplan/idempotency` keyed by `{sagaId, stepName}` unless the action declares `idempotency: "manual"`.
#### Scenario: step completion advances to next step
- **WHEN** an action handler returns success-with-output for step N
- **THEN** the orchestrator emits `saga.step.completed` AND immediately requests step N+1
#### Scenario: retried step does not double-fire
- **WHEN** the orchestrator retries a step whose handler previously committed under the same `{sagaId, stepName}` key
- **THEN** the idempotency wrapper returns the cached outcome AND the handler is NOT re-entered
#### Scenario: manual idempotency opts out of the wrapper
- **WHEN** the action declares `idempotency: "manual"`
- **THEN** the handler is invoked without the idempotency wrapper AND is solely responsible for its own dedup
**Verified by:** `src/orchestrator/orchestrator.test.ts::"step completion advances to next step"` AND `src/idempotency/idempotency.test.ts::"retried step returns cached outcome without re-entry"` AND `src/idempotency/idempotency.test.ts::"manual opt-out bypasses idempotency wrapper"`
---
### Requirement: Reverse-order compensation on step failure
On any step failure (action returns failure-with-cause OR exceeds `timeoutMs`), the orchestrator SHALL run `compensate` handlers in reverse order over completed steps. Compensation handlers MUST be idempotent + retryable up to `maxCompensationAttempts` (default 5). Successful compensation moves the instance to `compensated`; unrecoverable compensation failure moves to `compensation_failed` AND emits `saga.instance.compensation_failed`.
#### Scenario: failure on step 3 compensates steps 2 and 1 in that order
- **WHEN** a 3-step saga fails on step 3 AND every compensate succeeds
- **THEN** `compensations` for step 2 and then step 1 are invoked in that order AND the instance ends in `compensated`
#### Scenario: compensation max-attempts exceeded yields terminal failure
- **WHEN** a compensate handler fails on every attempt up to `maxCompensationAttempts`
- **THEN** the instance ends in `compensation_failed` AND `SAGA_COMPENSATION_MAX_ATTEMPTS_EXCEEDED` is emitted AND `saga.instance.compensation_failed` fires
**Verified by:** `src/compensation/compensation.test.ts::"failure on step 3 compensates steps 2 and 1 in reverse order"` AND `src/compensation/compensation.test.ts::"compensation max-attempts exceeded yields compensation_failed"`
---
### Requirement: Persisted state with replay after host restart
Every saga instance and every step transition SHALL be persisted to the host metadata DB (`suaveplan_saga_instances` and `suaveplan_saga_steps`, schema owned by `api-host`). On orchestrator boot the recovery runner MUST query incomplete instances and resume each — completing in-flight actions whose results have not yet been recorded, or re-emitting `saga.step.requested` where the request was never observed.
#### Scenario: orchestrator boot resumes an in-flight instance
- **WHEN** a saga's step 2 was `in_flight` when the previous orchestrator died AND a new orchestrator boots against the same DB
- **THEN** the recovery runner observes the persisted state AND resumes the saga from step 2 (re-emitting `saga.step.requested` is acceptable because the action handler is idempotent)
#### Scenario: completed instance is not re-replayed
- **WHEN** boot recovery scans the DB
- **THEN** instances in terminal states (`completed`, `compensated`, `compensation_failed`, `cancelled`) are NOT re-replayed
**Verified by:** `src/persistence/persistence.test.ts::"orchestrator boot resumes in-flight instance from persisted state"` AND `src/persistence/persistence.test.ts::"terminal-state instances are not re-replayed on boot"`
---
### Requirement: Cross-tenant capability gate
A saga step whose target tenant differs from the saga's initiator tenant SHALL require the saga owner's manifest to declare the `saga.cross-tenant` capability. Step dispatch without this capability MUST fail with `SAGA_CROSS_TENANT_DENIED`.
#### Scenario: same-tenant step proceeds without the capability
- **WHEN** a saga's target tenant equals the initiator tenant AND the owner does NOT declare `saga.cross-tenant`
- **THEN** step dispatch proceeds normally
#### Scenario: cross-tenant step without capability is refused
- **WHEN** a saga's target tenant differs from the initiator AND the owner does NOT declare `saga.cross-tenant`
- **THEN** dispatch fails with `SAGA_CROSS_TENANT_DENIED`
**Verified by:** `src/tenant/cross-tenant.test.ts::"same-tenant saga proceeds without capability"` AND `src/tenant/cross-tenant.test.ts::"cross-tenant step refused without saga.cross-tenant"`
---
### Requirement: OTel instruments cover instance and step lifecycle
The orchestrator SHALL emit, against an injected OTel meter and tracer:
- `saga.instance` span per saga lifetime (attributes: `saga.name`, `saga.tenant`, `saga.outcome`).
- `saga.step` span per step attempt (attributes: `saga.name`, `step.name`, `attempt`, `outcome`).
- `saga.compensate` span per compensation invocation.
- `saga.instances_total` counter labelled `{ name, outcome }`.
- `saga.step_duration_ms` histogram labelled `{ name, step, outcome }`.
- `saga.compensations_total` counter labelled `{ name, step }`.
- `saga.in_flight` observable gauge of currently-running instances.
#### Scenario: full instance emits matching counter + span pair
- **WHEN** a saga runs to `completed`
- **THEN** an in-memory `MeterProvider` observes one `saga.instances_total{outcome="completed"}` increment AND one `saga.instance` span for the run
**Verified by:** `src/instruments/instruments.test.ts::"emits instance span and counter on completed saga"` AND `src/instruments/instruments.test.ts::"emits step_duration_ms histogram per step"`
---
### Requirement: Conformance suite published for participants
The package SHALL publish `runSagaParticipantConformance(participantFactory)` on the `./testing` subpath. The suite MUST pin: handler signature shape, idempotency-key contract, compensation-handler signature, error-mapping into `saga.step.failed`, and the `RequestContext` field set visible inside a step handler.
#### Scenario: a reference participant passes the suite
- **WHEN** the suite runs against the in-package reference participant
- **THEN** every invariant passes
**Verified by:** `src/orchestrator/orchestrator.spec-conformance.test.ts::"reference participant passes runSagaParticipantConformance"`

View File

@@ -0,0 +1,129 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-sandbox-permissions (api-sandbox-permissions)
**Package**: `@suaveplan/api-sandbox-permissions`
**Status**: SHIPPED (2026-05-26)
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## Requirements
### Requirement: Permission policy is derived from the manifest
The package SHALL expose `PermissionPolicy` built exclusively from the `PermissionsManifest` block (`permissions.fs[]`, `permissions.fetch[]`, `permissions.budgets`). The policy MUST normalise filesystem paths (resolve `..`, strip trailing slashes) and compile glob patterns at construction time so subsequent checks are O(rules).
#### Scenario: policy reflects manifest exactly
- **WHEN** a manifest declares `permissions.fs = ["./uploads/*"]` and `permissions.fetch = ["api.stripe.com"]`
- **THEN** `PermissionPolicy` exposes those rules verbatim AND rejects any path or host not matched
#### Scenario: traversal patterns are normalised before matching
- **WHEN** a check is performed against a path containing `..`
- **THEN** the path is resolved before the glob match AND a resolution that escapes the allowed prefix is denied
**Verified by:** `src/policy/policy.test.ts::"PermissionPolicy reflects manifest verbatim"` AND `src/checker/fs/fs.test.ts::"normalises traversal segments before matching"`
---
### Requirement: Filesystem check enforces allowlist
The package SHALL expose `PermissionChecker.checkFs(path, mode)` that returns `Allow` only when the resolved path matches at least one rule in `permissions.fs[]` AND the access `mode` is permitted (read-only is the default; write requires an explicit `rw:` rule prefix).
#### Scenario: allowed path under matching rule
- **WHEN** `checkFs("./uploads/abc.png", "read")` is called against `permissions.fs = ["./uploads/*"]`
- **THEN** the result is `Allow`
#### Scenario: denied path emits metric and error
- **WHEN** `checkFs("./etc/passwd", "read")` is called against `permissions.fs = ["./uploads/*"]`
- **THEN** the result is `Deny<"path-not-in-allowlist">` AND `plugin.permission_denied_total{plugin, capability="fs", reason="path-not-in-allowlist"}` increments by 1
**Verified by:** `src/checker/fs/fs.test.ts::"allows path matching allowlist"` AND `src/checker/checker.spec-conformance.test.ts::"fs denial emits permission_denied_total with reason label"`
---
### Requirement: Fetch check enforces host allowlist
The package SHALL expose `PermissionChecker.checkFetch(url)` that returns `Allow` only when the URL's host matches an entry in `permissions.fetch[]` (exact host or `*.suffix`) AND the scheme is `https:` (or `http:` when explicitly allowed by the rule prefix `http:`).
#### Scenario: allowed exact host
- **WHEN** `checkFetch("https://api.stripe.com/v1/charges")` is called against `permissions.fetch = ["api.stripe.com"]`
- **THEN** the result is `Allow`
#### Scenario: wildcard suffix matches subdomains only
- **WHEN** `checkFetch("https://eu.api.stripe.com/v1/charges")` is called against `permissions.fetch = ["*.stripe.com"]`
- **THEN** the result is `Allow` AND `checkFetch("https://stripe.com/...")` against the same rule is `Deny<"host-not-in-allowlist">`
#### Scenario: denied scheme
- **WHEN** `checkFetch("http://api.stripe.com")` is called against a rule without the `http:` prefix
- **THEN** the result is `Deny<"scheme-not-permitted">` AND the denial metric increments with `reason="scheme-not-permitted"`
**Verified by:** `src/checker/fetch/fetch.test.ts::"allows exact host in allowlist"` AND `src/checker/fetch/fetch.test.ts::"wildcard matches subdomains only"` AND `src/checker/checker.spec-conformance.test.ts::"fetch denial emits permission_denied_total"`
---
### Requirement: Capability payload gating
The package SHALL expose `PermissionChecker.checkCapability(capabilityId, payload)` that for `db` rejects queries against tables outside the plugin's declared `database.tablePrefix`, for `secrets` rejects names not present in the manifest's `secrets[]`, and for `events` rejects publishes outside `events.publishes[]` or subscribes outside `events.subscribes[]`.
#### Scenario: db query against unscoped table is denied
- **WHEN** `checkCapability("db", { table: "users_account" })` is called against a manifest declaring `database.tablePrefix = "billing_"`
- **THEN** the result is `Deny<"table-out-of-scope">`
#### Scenario: secret read for un-manifested name is denied
- **WHEN** `checkCapability("secrets", { name: "AWS_KEY" })` is called against a manifest declaring `secrets = [{ name: "STRIPE_SECRET_KEY" }]`
- **THEN** the result is `Deny<"secret-not-declared">` AND the denial metric increments with `capability="secrets"`
**Verified by:** `src/checker/capability/capability.test.ts::"db denies queries outside declared tablePrefix"` AND `src/checker/capability/capability.test.ts::"secrets denies undeclared name"` AND `src/checker/capability/capability.test.ts::"events denies publish outside manifest"`
---
### Requirement: Budgets are tracked and breached deterministically
The package SHALL expose `BudgetTracker` that consumes CPU samples, memory samples, and a `Clock` tick to determine whether `permissions.budgets.cpuPercent`, `memoryMb`, or `wallClockMs` is exceeded. A breach raises `BUDGET_EXCEEDED` with `details.budget` naming the breached dimension.
#### Scenario: wall-clock breach raises BUDGET_EXCEEDED
- **WHEN** `permissions.budgets.wallClockMs = 30000` and the request elapsed reaches 30001ms on the supplied `Clock`
- **THEN** `BudgetTracker.tick()` raises `BUDGET_EXCEEDED` with `details.budget = "wallClockMs"` AND `plugin.budget_exceeded_total{plugin, budget="wallClockMs"}` increments by 1
#### Scenario: budget tracker is idempotent on repeated samples
- **WHEN** a sample identical to the previous one is supplied
- **THEN** the tracker raises at most one breach for that dimension (no double-counting)
**Verified by:** `src/budget/budget.test.ts::"raises BUDGET_EXCEEDED on wall-clock breach"` AND `src/budget/budget.test.ts::"is idempotent on duplicate samples"`
---
### Requirement: PERMISSION_DENIED surfaces inside the plugin
The package SHALL raise `PERMISSION_DENIED` errors that are reconstructible across the worker boundary (cause-chain preserved, `details` redaction-safe). The error code is registered via `defineCodes` so consumers can pin it in their `error-codes.ts`.
#### Scenario: error envelope carries reason and capability
- **WHEN** any `Deny<reason>` path runs
- **THEN** the raised `SandboxPermissionError` has `code === "SAND_PERM.DENIED"` AND `context = { capability, reason }` (the redaction-safe wire envelope) AND `message` does not include the denied path or host
**Verified by:** `src/checker/checker.spec-conformance.test.ts::"PERMISSION_DENIED error envelope is redaction-safe"`
---
### Requirement: Conformance harness is exported
The package SHALL expose `runPermissionsConformance(label, makeChecker)` on its `./testing` subpath. Every sandbox host (`api-sandbox-vm`, `api-sandbox-wasm`, and any future host) MUST pass this harness; the harness exercises every documented allow / deny / budget scenario above.
#### Scenario: in-package fixture passes the harness
- **WHEN** `runPermissionsConformance("self", makeFixtureChecker)` runs
- **THEN** every harness `it(...)` passes against the fixture
**Verified by:** `src/testing/run-permissions-conformance.test.ts::"in-package fixture passes runPermissionsConformance"`

View File

@@ -0,0 +1,102 @@
# Capability Spec Delta: api-sandbox-vm-host-bridge
**Package**: `@suaveplan/api-sandbox-vm-host-bridge`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Wraps a `VmSandboxHandle` as a `PluginModule`
`createSandboxedVmPluginModule({ sandbox, manifest })` SHALL return a `PluginModule` compatible with `@suaveplan/api-host.installPlugin(spec)`. The returned module's `routes` field is sourced from the sandboxed plugin's `__suaveplan.routes()` export, called once at bridge-init time. Each route's `handler` is a closure that marshals the request, invokes the sandbox, and unmarshals the response.
#### Scenario: routes from sandbox match host registration
- **WHEN** the sandboxed plugin exports two routes (`GET /foo`, `POST /bar`) and `createSandboxedVmPluginModule` is called
- **THEN** the returned PluginModule's `routes` array contains exactly those two routes with matching paths and methods
#### Scenario: host registers the routes via installPlugin
- **WHEN** `host.installPlugin({ kind: "vm-sandboxed", module, manifest })` is called
- **THEN** the host's router (queried via `host.routes()`) reports both routes as installed
**Verified by:** `src/bridge/bridge.test.ts::"PluginModule.routes mirrors sandboxed plugin's exported routes"` AND `tests/integration/full-flow.test.ts::"host registers sandboxed routes via installPlugin"`
---
### Requirement: Round-trips a real request through the sandbox
A request that reaches a sandboxed route handler MUST be fully marshalled (URL, method, headers, body bytes, principal id, tenant id, capability snapshot) into a `SerializedRequest`, invoked via the sandbox boundary, and the returned `SerializedResponse` unmarshalled into a host `Response`.
#### Scenario: request body and headers traverse the bridge intact
- **WHEN** a POST with JSON body + a custom header reaches a sandboxed handler
- **THEN** the sandbox-side handler observes the exact body bytes and the header value, and its returned response (status + body) reaches the host caller
#### Scenario: principal and tenant context are visible to the sandbox
- **WHEN** an authenticated request with `principal.id="u1"` and `tenant.id="t1"` reaches a sandboxed handler
- **THEN** the handler observes `serialized.principal.id === "u1"` and `serialized.tenant.id === "t1"`
**Verified by:** `tests/integration/full-flow.test.ts::"POST body and custom header traverse the bridge"` AND `tests/integration/full-flow.test.ts::"principal and tenant reach the sandbox handler"`
---
### Requirement: Lifecycle delegation is idempotent
The bridge's `PluginModule.start()` MUST delegate to `sandbox.start()`; `dispose()` MUST delegate to `sandbox.dispose()`. Both must be idempotent — calling twice is a no-op (no double-start, no double-dispose error).
#### Scenario: dispose called twice is a no-op
- **WHEN** the bridge's `dispose()` is called twice
- **THEN** the second call returns without error AND the sandbox's `dispose()` was invoked exactly once
**Verified by:** `src/lifecycle/lifecycle.test.ts::"dispose is idempotent"` AND `src/lifecycle/lifecycle.test.ts::"start delegates to sandbox.start exactly once"`
---
### Requirement: Errors propagate through SuaveplanError with cause-chain
When a sandboxed handler throws, the bridge MUST catch the throw, wrap it in a `SandboxVmBridgeError` with `code: HANDLER_REJECTED` and `cause` pointing at the original `SandboxVmError` (or the raw thrown value). The host's error boundary sees the bridge error AND can walk the cause-chain to surface the sandbox's stack.
#### Scenario: sandbox throw surfaces as a HANDLER_REJECTED bridge error
- **WHEN** a sandboxed handler throws a `SandboxVmError`
- **THEN** the bridge surfaces a `SandboxVmBridgeError` whose `code === "SAND_VM_BRIDGE.HANDLER_REJECTED"` AND whose `cause` is the original `SandboxVmError`
#### Scenario: marshal failures surface as MARSHAL_FAILED
- **WHEN** the bridge cannot serialise a request (e.g. body is an unsupported type)
- **THEN** the bridge surfaces a `SandboxVmBridgeError` with `code === "SAND_VM_BRIDGE.MARSHAL_FAILED"` AND the route handler does NOT reach the sandbox
**Verified by:** `src/bridge/bridge.test.ts::"sandbox throw surfaces as HANDLER_REJECTED"` AND `src/marshal/marshal-request.test.ts::"unsupported body type surfaces as MARSHAL_FAILED"`
---
### Requirement: Capability proxy passthrough
A sandboxed handler that calls `__suaveplan.capabilities.<name>.<method>(...)` MUST reach the host's tenant-scoped capability via the sandbox's permission gate. Denied capabilities surface `SAND_PERM.DENIED` from `@suaveplan/api-sandbox-permissions` — NOT a silent failure or a host-side crash.
#### Scenario: allowed capability reaches the host
- **WHEN** the sandbox policy includes `capabilities: ["db.query"]` AND a handler calls `__suaveplan.capabilities.db.query(...)`
- **THEN** the host's db adapter receives the query AND the response reaches the handler
#### Scenario: denied capability surfaces SAND_PERM.DENIED
- **WHEN** the sandbox policy excludes `capabilities.fs.write` AND a handler attempts `__suaveplan.capabilities.fs.write(...)`
- **THEN** the handler observes a thrown error with `code === "SAND_PERM.DENIED"`
**Verified by:** `tests/integration/full-flow.test.ts::"allowed capability reaches host db adapter"` AND `tests/integration/full-flow.test.ts::"denied capability surfaces SAND_PERM.DENIED"`
---
### Requirement: Conformance harness `runVmHostBridgeConformance`
The package SHALL ship `runVmHostBridgeConformance({ buildBridge })` from `@suaveplan/api-sandbox-vm-host-bridge/testing` with five invariants (route-registration, request-round-trip, lifecycle, error-propagation, capability-gate). Every concrete bridge configuration MUST pass.
#### Scenario: harness rejects a bridge that swallows errors silently
- **WHEN** `runVmHostBridgeConformance` runs against a stub bridge whose marshal-failure path returns `null` instead of throwing
- **THEN** the harness reports the error-propagation invariant failure
**Verified by:** `src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.test.ts::"harness rejects silent-failure bridge"`

View File

@@ -0,0 +1,101 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-sandbox-vm
**Package**: `@suaveplan/api-sandbox-vm`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: Sandbox boots inside a frozen `vm.Context`
The package SHALL expose `createVmSandbox({ pluginModulePath, policy, proxies })` that constructs a `vm.Context` whose `globalThis` is sealed and frozen. The sandbox MUST expose only the curated `FrozenGlobals` allowlist (`URL`, `URLSearchParams`, `TextEncoder`, `TextDecoder`, `structuredClone`, logger-routed `console`, subset of `crypto`). Any other global (e.g. `process`, `Buffer`, raw outbound HTTP client, raw `require`, `import.meta.url` outside the sandbox path) MUST be absent.
#### Scenario: process global is unreachable
- WHEN code inside the sandbox references `process`
- THEN evaluation throws a `ReferenceError` AND the sandbox surfaces it as a `SAND_VM_*`-prefixed `SuaveplanError`
#### Scenario: globalThis is frozen
- WHEN code inside the sandbox attempts `globalThis.foo = 1`
- THEN the assignment throws under strict mode AND no property is added to the sandbox globals
**Verified by:** `src/sandbox/sandbox.test.ts::"process global is unreachable inside sandbox"` AND `src/sandbox/sandbox.test.ts::"globalThis is frozen"`
---
### Requirement: Dynamic code constructors are rejected
The package SHALL ensure direct calls to dynamic-code constructors (the `Function` family — sync, async, generator), direct dynamic-code execution, and indirect dynamic-code execution all throw inside the sandbox. The thrown error MUST be a `SuaveplanError` with code `SAND_VM_EVAL_REJECTED` or `SAND_VM_INDIRECT_EVAL_REJECTED`.
#### Scenario: direct dynamic execution is rejected
- WHEN sandboxed code attempts to evaluate a string of source via the built-in dynamic-evaluator
- THEN the call throws `SAND_VM_EVAL_REJECTED`
#### Scenario: indirect dynamic execution is rejected
- WHEN sandboxed code attempts indirect dynamic evaluation through the comma-operator trick
- THEN the call throws `SAND_VM_INDIRECT_EVAL_REJECTED`
**Verified by:** `src/sandbox/sandbox.test.ts::"direct dynamic execution is rejected"` AND `src/sandbox/sandbox.test.ts::"indirect dynamic execution is rejected"`
---
### Requirement: fs and fetch are host-proxied only
The package SHALL install `fs` and outbound-HTTP proxy stubs that route every call to the host via `installCapabilityProxies`. Every proxy call MUST consult `PermissionChecker` from `api-sandbox-permissions` BEFORE forwarding to the host. Denied calls MUST raise `PERMISSION_DENIED` inside the sandbox without contacting the host.
#### Scenario: allowed fs read proxies to host
- WHEN sandboxed code calls the fs-proxy with a path matching `permissions.fs[]`
- THEN the request reaches the host adapter once AND the response is delivered to the sandbox
#### Scenario: denied outbound HTTP does not reach the host
- WHEN sandboxed code calls the outbound-HTTP proxy with a host outside `permissions.fetch[]`
- THEN the call raises `PERMISSION_DENIED` inside the sandbox AND no host-side outbound request is initiated AND `plugin.permission_denied_total{capability="fetch"}` increments
**Verified by:** `src/proxies/fs-proxy/fs-proxy.test.ts::"allowed read proxies once to host"` AND `src/proxies/fetch-proxy/fetch-proxy.test.ts::"denied fetch never contacts host AND increments denial metric"`
---
### Requirement: Module loading is restricted to the plugin's own module
The package SHALL refuse `vm.SourceTextModule` import specifiers that resolve outside the plugin module graph. Only relative imports rooted at `pluginModulePath` and a fixed allowlist of platform globals (no node built-ins) are loadable. Forbidden imports raise `SAND_VM_MODULE_IMPORT_FORBIDDEN`.
#### Scenario: node built-in import is rejected
- WHEN sandboxed code imports `"node:fs"`
- THEN module instantiation throws `SAND_VM_MODULE_IMPORT_FORBIDDEN`
**Verified by:** `src/sandbox/sandbox.test.ts::"node built-in imports are forbidden"`
---
### Requirement: Conformance harness exported
The package SHALL export `runVmSandboxConformance(label, makeSandbox)` on `./testing` and the in-package fixture sandbox MUST pass it. The harness re-runs every scenario above so a future vm-based host (e.g. an alternative isolate library) is gate-checked against the same contract.
#### Scenario: in-package fixture passes harness
- WHEN `runVmSandboxConformance("self", makeFixtureSandbox)` runs
- THEN every harness `it(...)` passes
**Verified by:** `src/testing/run-vm-conformance.test.ts::"in-package fixture passes runVmSandboxConformance"`
---
### Requirement: Disposal is idempotent
The package SHALL ensure `VmSandboxHandle.dispose()` releases the `vm.Context`, cancels every in-flight proxy call, and is safe to invoke multiple times. A second call MUST be a no-op (no error, no double-free).
#### Scenario: dispose twice is a no-op
- WHEN `dispose()` is called twice on the same handle
- THEN the second call returns without error AND no second teardown side-effect is observed
**Verified by:** `src/sandbox/sandbox.spec-conformance.test.ts::"dispose is idempotent"`

View File

@@ -0,0 +1,101 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-sandbox-wasm (api-sandbox-wasm)
**Package**: `@suaveplan/api-sandbox-wasm`
**Status**: ACCEPTED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: WASM module instantiated with a permission-derived WASI snapshot
The package SHALL expose `createWasmSandbox({ wasmModulePath, policy, proxies })` that instantiates the WASM module against a WASI snapshot built from the `PermissionPolicy`. The instantiation MUST fail with `SAND_WASM_WASI_VERSION_UNSUPPORTED` for unsupported WASI versions and with `SAND_WASM_INSTANTIATION_FAILED` for any other instantiation error.
#### Scenario: instantiation honours the policy
- WHEN `createWasmSandbox` is called with a policy declaring `permissions.fs = ["./uploads/*"]`
- THEN the resulting WASI snapshot exposes a preopen for `./uploads` AND no other directory
#### Scenario: unsupported WASI version rejected
- WHEN the WASM module imports `wasi_snapshot_preview0` (or any version not on the supported list)
- THEN instantiation throws `SAND_WASM_WASI_VERSION_UNSUPPORTED`
**Verified by:** `src/sandbox/sandbox.test.ts::"instantiation honours the policy"` AND `src/sandbox/sandbox.test.ts::"rejects unsupported WASI version"`
---
### Requirement: Filesystem access via read-only preopens by default
The package SHALL map `permissions.fs[]` to read-only WASI preopen directories. A rule prefix `rw:` is required to make a preopen read-write. Paths outside the allowlist MUST NOT be preopened so the guest cannot open them. Denials surface as `SAND_WASM_PREOPEN_DENIED` AND increment `plugin.permission_denied_total{capability="fs"}`.
#### Scenario: read-only by default
- WHEN `permissions.fs = ["./uploads/*"]` is given
- THEN the preopen for `./uploads` is read-only AND a guest write into that preopen returns the WASI errno for read-only
#### Scenario: write access requires explicit prefix
- WHEN `permissions.fs = ["rw:./uploads/*"]` is given
- THEN the preopen is read-write AND a guest write succeeds
**Verified by:** `src/preopens/preopens.test.ts::"read-only by default"` AND `src/preopens/preopens.test.ts::"rw prefix enables write"`
---
### Requirement: Outbound socket allowlist gates network connects
The package SHALL refuse outbound socket connects whose destination host is not in `permissions.fetch[]`. The refusal MUST raise `SAND_WASM_OUTBOUND_DENIED` BEFORE any socket is opened. Denials increment `plugin.permission_denied_total{capability="fetch"}`.
#### Scenario: allowed destination connects through
- WHEN a guest connects to `api.stripe.com` against `permissions.fetch = ["api.stripe.com"]`
- THEN the connect proceeds to the underlying socket
#### Scenario: denied destination never opens a socket
- WHEN a guest attempts to connect to `evil.example.com` against the same allowlist
- THEN the connect raises `SAND_WASM_OUTBOUND_DENIED` AND `plugin.permission_denied_total{capability="fetch"}` increments AND no OS socket is created
**Verified by:** `src/outbound/outbound.test.ts::"allowed destination connects"` AND `src/outbound/outbound.test.ts::"denied destination never opens a socket AND increments denial metric"`
---
### Requirement: Capability imports are permission-gated
The package SHALL expose capability proxies as WASI host imports (or component-model imports). Every imported function MUST consult `PermissionChecker` from `api-sandbox-permissions` before forwarding to the host proxy. Denied calls return a WASI errno and surface as `PERMISSION_DENIED` inside the guest.
#### Scenario: denied secret read returns errno
- WHEN guest calls the `secrets.get` import for a name not in the manifest
- THEN the import returns the WASI errno for permission-denied AND the host adapter is never invoked
**Verified by:** `src/sandbox/sandbox.spec-conformance.test.ts::"denied capability import never reaches host adapter"`
---
### Requirement: Conformance harness exported
The package SHALL export `runWasmSandboxConformance(label, makeSandbox)` on `./testing`. The in-package fixture sandbox MUST pass every scenario.
#### Scenario: fixture passes harness
- WHEN `runWasmSandboxConformance("self", makeFixtureSandbox)` runs
- THEN every harness `it(...)` passes
**Verified by:** `src/testing/run-wasm-conformance.test.ts::"in-package fixture passes runWasmSandboxConformance"`
---
### Requirement: Disposal is idempotent
The package SHALL ensure `WasmSandboxHandle.dispose()` tears down the WASM instance, closes every preopen file descriptor, drops the outbound socket allowlist reference, and is safe to call multiple times.
#### Scenario: dispose twice is a no-op
- WHEN `dispose()` is called twice
- THEN the second call returns without error
**Verified by:** `src/sandbox/sandbox.spec-conformance.test.ts::"dispose is idempotent"`

View File

@@ -0,0 +1,60 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-secrets-bun (api-secrets-bun)
**Package**: `@suaveplan/api-secrets-bun`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Bun-keystore-backed secrets adapter implements `secrets`
The package SHALL implement the abstract `secrets` capability from `api-secrets` using `Bun.secrets` (macOS Keychain, Linux libsecret, Windows Credential Manager).
#### Scenario: get returns stored value
- WHEN a secret has been written via the adapter
- THEN `get(name)` returns its value AND increments `api.secrets.reads_total{backend="bun"}`
**Verified by:** `src/adapter/adapter.test.ts::"get returns stored value AND increments reads_total"`
---
### Requirement: Per-plugin allowlist enforcement
The package SHALL refuse reads of secret names not in the plugin manifest's `secrets[]`. Refusals raise `SECRETS_BUN_NOT_ALLOWED`.
#### Scenario: un-manifested read is refused
- WHEN a plugin's manifest declares `secrets = [{ name: "STRIPE" }]` AND code calls `get("AWS")`
- THEN the call raises `SECRETS_BUN_NOT_ALLOWED`
**Verified by:** `src/allowlist/allowlist.test.ts::"un-manifested read raises SECRETS_BUN_NOT_ALLOWED"`
---
### Requirement: Locked keystore surfaces explicitly
The package SHALL distinguish a locked keystore (`SECRETS_BUN_KEYSTORE_LOCKED`) from a missing entry (`SECRETS_BUN_NOT_FOUND`). The adapter MUST NOT fall back silently when the OS keystore is locked.
#### Scenario: locked keystore raises specific code
- WHEN the OS keystore is locked
- THEN reads raise `SECRETS_BUN_KEYSTORE_LOCKED` AND no plaintext fallback occurs
**Verified by:** `src/adapter/adapter.test.ts::"locked keystore raises SECRETS_BUN_KEYSTORE_LOCKED"`
---
### Requirement: Passes `runSecretsConformance`
The package SHALL pass the conformance harness exported by `@suaveplan/api-secrets/testing`. This pins the Bun adapter to the same observable contract as every other secrets backend.
#### Scenario: conformance passes
- WHEN `runSecretsConformance("bun", makeBunAdapter)` runs
- THEN every harness `it(...)` passes
**Verified by:** `src/adapter/adapter.spec-conformance.test.ts::"Bun adapter passes runSecretsConformance"`

View File

@@ -0,0 +1,85 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-secrets-env (api-secrets-env)
**Package**: `@suaveplan/api-secrets-env`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Read secrets from environment
The package SHALL implement `SecretsLike.get(name)` by reading from `Bun.env` first, falling back to `process.env`. Resolution honours the configured `prefix` (e.g. `BILLING_` → `get("STRIPE_KEY")` reads `BILLING_STRIPE_KEY`) and `caseSensitive` flag.
#### Scenario: present env var returns its value
- **WHEN** `STRIPE_KEY=sk_test_123` is set AND `get("STRIPE_KEY")` is called
- **THEN** the result is `"sk_test_123"`
#### Scenario: missing env var returns undefined
- **WHEN** `NOT_SET` is not in env AND `get("NOT_SET")` is called
- **THEN** the result is `undefined`
#### Scenario: prefix is honoured
- **WHEN** the adapter is constructed with `prefix: "BILLING_"` AND env contains `BILLING_STRIPE_KEY=...`
- **THEN** `get("STRIPE_KEY")` returns the value
**Verified by:** `src/factory/create-env-secrets.test.ts::"returns env value when set"` AND `src/factory/create-env-secrets.test.ts::"returns undefined when not set"` AND `src/factory/create-env-secrets.test.ts::"honours configured prefix"`
---
### Requirement: Fail-fast against plugin manifest
The package SHALL expose `validateAgainstManifest(manifest)` which iterates `manifest.secrets[]` and throws `SECRETS_ENV_REQUIRED_MISSING` (with `details.missing: string[]`) when any `required: true` entry resolves to undefined. Optional entries that resolve to undefined MUST NOT trigger.
#### Scenario: missing required secret throws with named list
- **WHEN** manifest declares `secrets: [{ name: "A", required: true }, { name: "B", required: true }]` AND only `A` is in env
- **THEN** `validateAgainstManifest()` throws `SECRETS_ENV_REQUIRED_MISSING` AND `error.details.missing === ["B"]`
#### Scenario: missing optional secret is silent
- **WHEN** manifest declares `secrets: [{ name: "X", required: false }]` AND `X` is not in env
- **THEN** `validateAgainstManifest()` returns without throwing
**Verified by:** `src/manifest-validation/validate-against-manifest.test.ts::"throws with named list when required secrets are missing"` AND `src/manifest-validation/validate-against-manifest.test.ts::"is silent when only optional secrets are missing"`
---
### Requirement: Secret values never appear in logs or telemetry
The package SHALL route every error message, log line, and metric label through `@suaveplan/data-masking`. Raw secret values MUST NEVER appear in `error.message`, `error.details`, OTel metric attribute values, or trace event attributes. Only secret NAMES are loggable.
#### Scenario: error stringification redacts values
- **WHEN** an internal failure occurs while reading `SECRET=raw_value` AND the resulting error is stringified
- **THEN** the resulting string contains `SECRET` AND does NOT contain `raw_value`
#### Scenario: metric labels carry only names
- **WHEN** `api.secrets.get_total{source=env,outcome=hit,name=SECRET}` is emitted
- **THEN** the attribute set contains `name=SECRET` AND no attribute contains the value
**Verified by:** `src/redaction/redaction.test.ts::"error messages redact secret values"` AND `src/redaction/redaction.test.ts::"metric labels carry names but never values"`
---
### Requirement: Direct and worker-proxy surfaces both pass conformance
The worker-proxy surface MUST NOT read env in the worker process — every `get()` round-trips to the main process so secrets stay materialised on one side.
#### Scenario: direct conforms
- **WHEN** `runSecretsConformance("env-direct", () => createEnvSecrets(...))` runs
- **THEN** every invariant passes
#### Scenario: worker-proxy conforms AND worker never reads env
- **WHEN** `runSecretsConformance("env-worker-proxy", ...)` runs in a real Worker with `Bun.env = {}` enforced on the worker side
- **THEN** every `get()` still returns the host's env value (proving the proxy round-trips) AND every invariant passes
**Verified by:** `src/factory/create-env-secrets.spec-conformance.test.ts::"env-direct conforms to Secrets contract"` AND `src/proxy/create-env-secrets-worker-proxy.spec-conformance.test.ts::"env-worker-proxy conforms to Secrets contract AND worker env is never read"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,78 @@
<!-- markdownlint-disable MD024 -->
# Capability: api-secrets-vault
**Package**: `@suaveplan/api-secrets-vault`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line.
## Requirements
### Requirement: Vault-backed adapter implements `secrets`
The package SHALL implement the abstract `secrets` capability over a `@suaveplan/vault` instance. The vault is passed in already unlocked; the adapter MUST NOT see the master key.
#### Scenario: get returns stored value
- WHEN a secret is set on the underlying vault AND `get(name)` is called
- THEN the adapter returns the value AND `api.secrets.reads_total{backend="vault"}` increments
**Verified by:** `src/adapter/adapter.test.ts::"get returns stored value AND increments reads_total"`
---
### Requirement: Locked vault surfaces explicitly
The package SHALL distinguish a locked vault (`SECRETS_VAULT_LOCKED`) from a missing entry (`SECRETS_VAULT_NOT_FOUND`).
#### Scenario: locked vault raises specific code
- WHEN the vault is locked at read time
- THEN reads raise `SECRETS_VAULT_LOCKED` AND no plaintext fallback occurs
**Verified by:** `src/adapter/adapter.test.ts::"locked vault raises SECRETS_VAULT_LOCKED"`
---
### Requirement: Per-plugin allowlist enforcement
The package SHALL refuse reads of secret names not in the plugin manifest's `secrets[]`. Refusals raise `SECRETS_VAULT_NOT_ALLOWED`.
#### Scenario: un-manifested read refused
- WHEN a plugin's manifest declares `secrets = [{ name: "STRIPE" }]` AND code calls `get("AWS")`
- THEN the call raises `SECRETS_VAULT_NOT_ALLOWED`
**Verified by:** `src/allowlist/allowlist.test.ts::"un-manifested read raises SECRETS_VAULT_NOT_ALLOWED"`
---
### Requirement: Vault updates surface as `secret.changed` events
The package SHALL subscribe to the vault's `secret.updated` event AND, when the updated secret is in the plugin's allowlist, emit `secret.changed` on the host event bus carrying `{ name }` only (no value).
#### Scenario: allowlisted update emits secret.changed
- WHEN the vault publishes `secret.updated` for a name in the allowlist
- THEN the host bus receives exactly one `secret.changed` event AND the payload contains only `{ name }`
#### Scenario: non-allowlisted update does not emit
- WHEN the vault publishes `secret.updated` for a name NOT in the allowlist
- THEN no `secret.changed` event is emitted on the host bus
**Verified by:** `src/watcher/watcher.test.ts::"allowlisted update emits secret.changed"` AND `src/watcher/watcher.test.ts::"non-allowlisted update does not emit"`
---
### Requirement: Passes `runSecretsConformance`
The package SHALL pass `runSecretsConformance` from `@suaveplan/api-secrets/testing`.
#### Scenario: conformance passes
- WHEN `runSecretsConformance("vault", makeVaultAdapter)` runs
- THEN every harness `it(...)` passes
**Verified by:** `src/adapter/adapter.spec-conformance.test.ts::"vault adapter passes runSecretsConformance"`

View File

@@ -0,0 +1,78 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-secrets-worker-proxy
**Package**: `@suaveplan/api-secrets-worker-proxy`
**Status**: SHIPPED (0.1.0)
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Proxy preserves SecretsLike surface and forwards every call to main
The package SHALL expose `createSecretsProxyClient(port)` returning a `SecretsLike`-compatible proxy and `createSecretsProxyServer(port, secrets, allowedKeys)` returning a `WorkerRpcServer`. Every `get` and `require` call SHALL be marshalled to the main process — there MUST be no worker-side cache of secret values, neither in module state nor in client-instance state. Two sequential `get("X")` calls MUST produce TWO main-side RPC invocations (so a rotated secret is picked up immediately AND a compromised worker cannot exfiltrate cached values). `list()` is sync on the canonical contract and SHALL be served by a worker-side snapshot populated either at construction (`initialList`) or by a hidden `__refreshList()` accessor — name lists are manifest-pinned and not sensitive.
#### Scenario: two gets produce two main-side RPC calls
- **WHEN** worker code calls `ctx.secrets.get("X")` twice in succession and the source value is mutated between calls
- **THEN** the proxy observes the post-mutation value on the second call
**Verified by:** `src/worker-side/worker-side.test.ts::"two get() calls produce two main-side invocations (no worker-side cache)"`
---
### Requirement: Non-permitted key rejected on main side with SECRETS_PROXY.KEY_NOT_PERMITTED
The MAIN-side server SHALL enforce a per-plugin allowed-keys whitelist BEFORE consulting the underlying source. Requests for non-permitted names SHALL throw `SECRETS_PROXY.KEY_NOT_PERMITTED` and the source SHALL NOT be invoked. Permitted keys whose value is absent in the source SHALL surface as `SECRETS_PROXY.KEY_NOT_FOUND` from `require()` — the two cases are distinct so the operator can tell "your plugin didn't declare this" from "your plugin declared it but the source is missing it."
#### Scenario: non-permitted key is rejected before source consulted
- **WHEN** the whitelist is empty AND worker calls `ctx.secrets.get("LEAKED")` for a key the source DOES contain
- **THEN** the call rejects with `SECRETS_PROXY.KEY_NOT_PERMITTED` and the source is not consulted
#### Scenario: permitted-but-absent key surfaces SECRETS_PROXY.KEY_NOT_FOUND
- **WHEN** the whitelist permits `DECLARED` but the source has no value AND worker calls `require("DECLARED")`
- **THEN** the call rejects with `SECRETS_PROXY.KEY_NOT_FOUND`
**Verified by:** `src/main-side/main-side.test.ts::"non-permitted key rejects with KEY_NOT_PERMITTED before the source is consulted"` AND `src/main-side/main-side.test.ts::"require() on permitted-but-absent key rejects with KEY_NOT_FOUND"`
---
### Requirement: list() returns frozen allowedKeys snapshot, not the source's enumeration
The MAIN-side server's `list` handler SHALL return the FROZEN snapshot of `allowedKeys` supplied at server construction, NOT the underlying source's `list()` output. The worker SHALL be unable to enumerate names outside its manifest, regardless of source state.
#### Scenario: source contains extra names, list returns only allowed
- **WHEN** the source contains `ALLOWED` and `HIDDEN` AND only `ALLOWED` is in `allowedKeys` AND the worker calls `__refreshList()`
- **THEN** the snapshot is `["ALLOWED"]`
**Verified by:** `src/main-side/main-side.test.ts::"list() returns the frozen allowedKeys snapshot, not the source's list"`
---
### Requirement: close() transitions proxy to PROXY_DISPOSED for all methods
The worker-side proxy SHALL expose `close(reason?)`. After `close()`, every subsequent `get`, `require`, sync `list`, and async `__refreshList` call SHALL throw `SECRETS_PROXY.PROXY_DISPOSED`. The `close()` operation SHALL be idempotent.
#### Scenario: post-close methods all reject
- **WHEN** the worker calls `close()` and then invokes any proxy method
- **THEN** every call throws `SECRETS_PROXY.PROXY_DISPOSED`
**Verified by:** `src/worker-side/worker-side.test.ts::"close() makes subsequent calls reject with PROXY_DISPOSED"`
---
### Requirement: Conformance suite passes against the proxy surface
The package SHALL be runnable as the surface argument to `runSecretsConformance` from `@suaveplan/api-secrets/testing`. Every scenario MUST pass identically to a direct in-process adapter. This is the Stage-4 precondition gate.
#### Scenario: runSecretsConformance passes
- **WHEN** `runSecretsConformance({ label: "worker-proxy", factory })` runs against the proxy
- **THEN** every scenario passes
**Verified by:** `tests/conformance/run-secrets-conformance.test.ts`

View File

@@ -0,0 +1,147 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-secrets (api-secrets)
**Package**: `@suaveplan/api-secrets`
**Status**: SHIPPED 2026-05-25
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: SecretsLike re-export from api-types
The package SHALL re-export the canonical `SecretsLike` interface from `@suaveplan/api-types/secrets`. The canonical shape exposes `get(name): Promise<string | undefined>`, `require(name): Promise<string>` (throws `SECRETS.SECRET_NOT_FOUND` on miss), and `list(): readonly string[]` (enumerates declared names).
#### Scenario: get returns the value when present
- **WHEN** a stub source seeded with `{ A: "alpha" }` calls `get("A")`
- **THEN** the promise resolves to `"alpha"`
#### Scenario: get returns undefined when absent
- **WHEN** the same source calls `get("MISSING")`
- **THEN** the promise resolves to `undefined`
#### Scenario: require throws SECRET_NOT_FOUND on miss
- **WHEN** the source calls `require("MISSING")`
- **THEN** a `SecretsError` is thrown carrying `code === "SECRETS.SECRET_NOT_FOUND"`
**Verified by:** `src/secrets-like/secrets-like.test.ts::"SecretsLike contract — stub implementation > get returns the value when present"` AND `src/secrets-like/secrets-like.test.ts::"SecretsLike contract — stub implementation > get returns undefined when absent"` AND `src/secrets-like/secrets-like.test.ts::"SecretsLike contract — stub implementation > require throws SECRET_NOT_FOUND when absent"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import { z } from "zod"` in `src/**`
- **THEN** zero matches
**Verified by:** `tests/dep-graph.test.ts::"zod-import-form > every src file uses default-import z"`
---
### Requirement: SecretDescriptor schema
The package SHALL export `secretDescriptorSchema` describing `{ name: string, required: boolean, description?: string, format?: "string" | "base64" | "json" }`. Unknown keys MUST be rejected. The list variant `secretDescriptorListSchema` MUST accept the empty array.
#### Scenario: minimal descriptor parses
- **WHEN** `{ name: "STRIPE_KEY", required: true }` is parsed
- **THEN** it succeeds AND `format` is undefined
#### Scenario: unknown format rejected
- **WHEN** `{ name: "X", required: true, format: "weird" }` is parsed
- **THEN** Zod reports an issue at `format`
#### Scenario: unknown keys rejected
- **WHEN** `{ name: "X", required: true, requried: true }` (typo) is parsed
- **THEN** the parse fails
**Verified by:** `src/descriptor/descriptor.test.ts::"secretDescriptorSchema > minimal descriptor parses"` AND `src/descriptor/descriptor.test.ts::"secretDescriptorSchema > unknown format rejected"` AND `src/descriptor/descriptor.test.ts::"secretDescriptorSchema > unknown keys rejected"`
---
### Requirement: redactSecret returns the canonical marker
The package SHALL export `redactSecret(value: string): "<redacted>"` returning the literal string `"<redacted>"` for any input — including the empty string — and `REDACTED_MARKER` exposing the same literal.
#### Scenario: any input maps to canonical marker
- **WHEN** any string is passed to `redactSecret`
- **THEN** the result equals `"<redacted>"`
**Verified by:** `src/redaction/redaction.test.ts::"redactSecret > any input maps to canonical marker"`
---
### Requirement: CapabilityId — secrets and secrets.bun
The package SHALL export `SECRETS_CAPABILITY_ID` (literal `"secrets"`) and `SECRETS_BUN_CAPABILITY_ID` (literal `"secrets.bun"`), both typed as `CapabilityId` via `as const satisfies CapabilityId`. If api-types ever drops either id, this package's typecheck MUST fail.
#### Scenario: both ids assignable to CapabilityId
- **WHEN** the test assigns each constant to a `CapabilityId`-typed variable
- **THEN** the assignment compiles
**Verified by:** `src/capability-id/capability-id.test.ts::"capability-id constants > both ids are valid CapabilityId values"`
---
### Requirement: Required-secret enforcement at start
The package SHALL export `checkRequiredSecrets` (returns a discriminated `{ ok }` result) and `enforceRequiredSecrets` (throws `SecretsError(SECRETS.SECRET_REQUIRED_MISSING_AT_START)` listing missing names on `context.missing`). Hosts use these to gate the `installing → installed` transition.
#### Scenario: missing required secret throws SECRET_REQUIRED_MISSING_AT_START
- **WHEN** a stub source has no entry for `"X"` AND descriptors include `{ name: "X", required: true }`
- **THEN** `enforceRequiredSecrets` throws `SecretsError` with `code === "SECRETS.SECRET_REQUIRED_MISSING_AT_START"` AND `context.missing` lists `["X"]`
#### Scenario: optional descriptors ignored
- **WHEN** descriptors include `{ name: "OPT", required: false }` AND the source has no entry for `"OPT"`
- **THEN** `checkRequiredSecrets` returns `{ ok: true }`
**Verified by:** `src/secrets-like/secrets-like.test.ts::"enforceRequiredSecrets > throws SECRET_REQUIRED_MISSING_AT_START with missing list"` AND `src/secrets-like/secrets-like.test.ts::"checkRequiredSecrets > ignores optional descriptors"`
---
### Requirement: Error codes registry frozen
The package SHALL export `SECRETS_CODES` containing exactly: `SECRET_NOT_FOUND`, `SECRET_REQUIRED_MISSING_AT_START`, `SECRET_INVALID_FORMAT`, `SOURCE_UNAVAILABLE`. The registry MUST be `Object.freeze`d. The package SHALL also export `SecretsError` — a concrete `SuaveplanError<SecretsCode>` subclass with a public constructor.
#### Scenario: every documented code present
- **WHEN** the test enumerates `Object.keys(SECRETS_CODES)`
- **THEN** the sorted keys equal `["SECRET_INVALID_FORMAT", "SECRET_NOT_FOUND", "SECRET_REQUIRED_MISSING_AT_START", "SOURCE_UNAVAILABLE"]`
#### Scenario: registry is frozen
- **WHEN** `Object.isFrozen(SECRETS_CODES)` is evaluated
- **THEN** it equals `true`
**Verified by:** `src/error-codes/error-codes.test.ts::"SECRETS_CODES > contains exactly four keys"` AND `src/error-codes/error-codes.test.ts::"SECRETS_CODES > is frozen"`
---
### Requirement: Conformance harness for sources
The package SHALL export `runSecretsConformance({ label, factory, isNotFound? })`. The harness MUST register five scenarios per call: `get` resolves seeded value, `get` resolves undefined for missing, `require` resolves seeded value, `require` throws (per `isNotFound`) on miss, and `list()` enumerates seeded names. Every concrete source in Stage 4/8 MUST pass it.
#### Scenario: stub source passes default-predicate suite
- **WHEN** a stub `SecretsLike` is run through `runSecretsConformance` with the default predicate
- **THEN** all five scenarios pass
#### Scenario: custom isNotFound predicate honoured
- **WHEN** a source represents missing names as `throw new Error("CUSTOM_MISSING")` AND `isNotFound` checks for that message
- **THEN** the harness still passes
**Verified by:** `src/testing/conformance.test.ts::"stub source — default predicate — SecretsLike conformance > require throws SECRET_NOT_FOUND for missing names"` AND `src/testing/conformance.test.ts::"stub source — custom predicate — SecretsLike conformance > require throws SECRET_NOT_FOUND for missing names"`

View File

@@ -0,0 +1,158 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-sse-bun
**Package**: `@suaveplan/api-sse-bun`
**Status**: SHIPPED (2026-05-26)
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Implements the abstract SSE engine contract
The package SHALL export `createBunSseEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-sse`. The conformance suite `runSseEngineConformance` MUST pass against this adapter.
#### Scenario: conformance suite passes
- **WHEN** `runSseEngineConformance("bun", createBunSseEngine)` runs
- **THEN** every invariant passes
**Verified by:** `src/engine/bun-sse-engine.spec-conformance.test.ts::"createBunSseEngine passes runSseEngineConformance"`
---
### Requirement: SSE framing emits valid event-stream output
The engine SHALL emit `Content-Type: text/event-stream`. Each event MUST be framed as `id: <eventId>\nevent: <topic>\ndata: <JSON-encoded payload>\n\n`. A `retry: <retryMs>\n` line MUST be sent at stream start (default 3000) so clients back off appropriately on disconnect. Keep-alive comments `:keepalive\n\n` MUST be emitted every `keepAliveCommentMs` (default 15000) — invisible to clients but prevent intermediary proxy timeouts.
#### Scenario: first emitted event includes id, event, data lines
- **WHEN** a broadcast triggers an event delivered to a subscriber
- **THEN** the stream body contains `id: <n>`, `event: <topic>`, and `data: <json>` lines terminated by a blank line
#### Scenario: retry directive emitted at stream start
- **WHEN** a subscriber connects with default `retryMs: 3000`
- **THEN** the first bytes of the response include `retry: 3000\n`
#### Scenario: keep-alive comment cadence
- **WHEN** no real events for `keepAliveCommentMs + 1`ms
- **THEN** the stream contains at least one `:keepalive\n\n` line
**Verified by:** `src/stream/sse-stream.test.ts::"emitted event includes id, event, data lines"` AND `src/stream/sse-stream.test.ts::"retry directive emitted at stream start"` AND `src/keep-alive/keep-alive.test.ts::"keep-alive comment emitted every keepAliveCommentMs"`
---
### Requirement: Last-Event-Id replay before live delivery
When a client supplies `Last-Event-Id: <n>` header on connect (HTTP standard, automatically sent by browser `EventSource` on reconnect), the engine SHALL consult the event buffer for `(tenant, topic)` AND replay every event with id > n in order BEFORE switching to live delivery. The replay set MUST include at most `eventBufferSize` events; missed-window cases (buffer evicted older events) MUST set `SSE_BUN_BUFFER_MISS` on a comment line so the client can react. A malformed `Last-Event-Id` value MUST be refused with `SSE_BUN_LAST_EVENT_ID_INVALID`.
#### Scenario: replay delivers events with id > Last-Event-Id
- **WHEN** the buffer holds events 1..10 AND a client reconnects with `Last-Event-Id: 7`
- **THEN** events 8, 9, 10 are delivered in order BEFORE any new live event
#### Scenario: buffer miss surfaces as a comment line
- **WHEN** the buffer's oldest event is id 5 AND a client reconnects with `Last-Event-Id: 2`
- **THEN** the stream emits a comment line containing `SSE_BUN_BUFFER_MISS` AND switches to live delivery without replay
#### Scenario: malformed Last-Event-Id refused
- **WHEN** the header value is `"not-a-number"`
- **THEN** the connection is refused with `SSE_BUN_LAST_EVENT_ID_INVALID`
**Verified by:** `src/last-event-id/last-event-id.test.ts::"replay delivers events with id greater than Last-Event-Id"` AND `src/last-event-id/last-event-id.test.ts::"buffer miss surfaces SSE_BUN_BUFFER_MISS comment"` AND `src/last-event-id/last-event-id.test.ts::"malformed Last-Event-Id refused"`
---
### Requirement: Event buffer storage follows wired broker
The engine SHALL maintain a per-`(tenant, topic)` event buffer:
- When a broker (`api-ws-broker-redis`) is wired into the host, the buffer lives in the broker (Redis sorted-set shared with the WS engine).
- Otherwise the buffer lives in-memory on the local host as a ring buffer capped at `eventBufferSize` (default 256) with TTL `eventBufferMs` (default 300000).
This is the same default as `api-ws-bun`'s reconnect event-buffer strategy (design §11.5 paragraph 4), pinned for the SSE side here.
#### Scenario: broker-backed buffer used when wired
- **WHEN** a Redis broker is wired AND an event is broadcast
- **THEN** the event is observable in the broker's buffer surface (asserted via `broker.readBuffer(tenant, topic, 0)`)
#### Scenario: in-memory buffer used when no broker wired
- **WHEN** no broker is wired AND `eventBufferSize: 4` AND 5 events are emitted for the same `(tenant, topic)`
- **THEN** the in-memory buffer holds the 4 most recent
**Verified by:** `src/event-buffer/event-buffer.test.ts::"broker-backed buffer used when broker wired"` AND `src/event-buffer/event-buffer.test.ts::"in-memory buffer evicts oldest when broker not wired"`
---
### Requirement: Per-tenant connection cap
The engine SHALL track active SSE connections per tenant. A new connection request when a tenant is at `maxConnectionsPerTenant` (default 1000) MUST return HTTP 429 with `SSE_BUN_TENANT_CAP_EXCEEDED`.
#### Scenario: 1001st connection from same tenant rejected
- **WHEN** `maxConnectionsPerTenant: 1000` AND tenant T has 1000 active SSE connections
- **THEN** the 1001st returns 429 with `SSE_BUN_TENANT_CAP_EXCEEDED`
**Verified by:** `src/limits/tenant-cap.test.ts::"1001st SSE connection from same tenant rejected with SSE_BUN_TENANT_CAP_EXCEEDED"`
---
### Requirement: Same subscription bus as WebSocket engine
`conn.subscribe(topic, { tenant?, filter? })` SHALL register the SSE connection against the host's shared subscription bus — the same bus used by `api-ws-bun`. `bus.broadcast(topic, { tenant, payload })` MUST fan out to BOTH SSE and WS subscribers matching `(topic, tenant)` without any code path duplication between engines.
#### Scenario: WS and SSE subscribers both receive the same broadcast
- **WHEN** a WS client and an SSE client are both subscribed to `(topic, tenantT)` AND `bus.broadcast(topic, { tenant: T, payload })` fires
- **THEN** both clients receive the payload
**Verified by:** `tests/integration/end-to-end.test.ts::"WS and SSE subscribers both receive same broadcast via shared bus"`
---
### Requirement: Optional gzip compression
When `compressionEnabled: true` (default) AND the client sends `Accept-Encoding: gzip`, the engine SHALL emit `Content-Encoding: gzip` AND gzip-stream the response body. Compression failure (e.g. encoder error) MUST fall back to uncompressed streaming AND emit `SSE_BUN_COMPRESSION_FAILED` to logs (NOT to the client — they get a working uncompressed stream).
#### Scenario: gzip used when client accepts
- **WHEN** the client sends `Accept-Encoding: gzip`
- **THEN** the response has `Content-Encoding: gzip` AND the body decompresses to valid SSE framing
#### Scenario: compression failure falls back to plain
- **WHEN** the gzip encoder throws AND a client is mid-stream
- **THEN** the stream falls back to uncompressed delivery AND a log entry with `SSE_BUN_COMPRESSION_FAILED` is emitted
**Verified by:** `src/compression/compression.test.ts::"gzip emitted when client sends Accept-Encoding: gzip"` AND `src/compression/compression.test.ts::"compression failure falls back to plain with SSE_BUN_COMPRESSION_FAILED log"`
---
### Requirement: OTel surface
The engine SHALL emit:
- `sse.connections` observable gauge `{ plugin, path, tenant }`.
- `sse.events_sent_total` counter `{ plugin, topic }`.
- `sse.handshake.duration_ms` histogram.
- `sse.buffer_replays_total` counter `{ hit }` (`"hit"` when replay returned events, `"miss"` on `SSE_BUN_BUFFER_MISS`).
- `sse.compression_ratio` histogram (uncompressed-size / compressed-size when compression is on).
#### Scenario: event delivery emits events_sent_total
- **WHEN** a broadcast delivers to one SSE subscriber
- **THEN** `sse.events_sent_total{ plugin, topic }` increments by 1
#### Scenario: connection lifecycle reflected in gauge
- **WHEN** a connection opens then closes
- **THEN** the `sse.connections` gauge briefly rises then falls
**Verified by:** `src/instruments/instruments.test.ts::"event delivery emits sse.events_sent_total"` AND `src/instruments/instruments.test.ts::"connection lifecycle reflected in sse.connections gauge"`

View File

@@ -0,0 +1,159 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-sse
**Package**: `@suaveplan/api-sse`
**Status**: SHIPPED (0.1.0)
**Archived change**: `openspec/archive/2026-05-25-api-sse/`
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: SseConnection contract
The package SHALL export `SseConnection` with: `id` (ULID), `principal: Principal`, `tenantId: TenantId`, `lastEventId: string | null`, `state: "open" | "closing" | "closed"`, methods `send(event: SseEvent)`, `close()`. SSE is uni-directional (host → client); there is no `onMessage`.
#### Scenario: every documented field present
- **WHEN** `expectTypeOf<SseConnection>()` is checked
- **THEN** every documented field is declared
**Verified by:** `src/connection/connection.spec-conformance.test.ts::"SseConnection declares every documented field"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: SseEvent shape
The package SHALL export `sseEventSchema: z.ZodSchema<SseEvent>` describing `{ id: string, event?: string, data: string, retry?: number }`. Unknown keys MUST be rejected.
#### Scenario: minimal event parses
- **WHEN** `{ id: "1", data: "hello" }` is parsed
- **THEN** it succeeds AND `event` is undefined (not defaulted)
#### Scenario: unknown key rejected
- **WHEN** `{ id: "1", data: "x", extra: 1 }` is parsed
- **THEN** Zod reports an issue at `extra`
**Verified by:** `src/event/event.test.ts::"minimal event parses"` AND `src/event/event.test.ts::"unknown keys rejected"`
---
### Requirement: SseBroker contract
The package SHALL export `SseBroker` with `publish(topic, envelope)`, `subscribe(topic, handler)`, `dispose()`. Same shape as `WsBroker` but a distinct type so engines can supply different transports.
#### Scenario: every method present
- **WHEN** `expectTypeOf<SseBroker>()` is checked
- **THEN** all three methods are declared
**Verified by:** `src/broker/broker.spec-conformance.test.ts::"SseBroker declares publish subscribe dispose"`
---
### Requirement: Replay buffer with Last-Event-Id resume
The package SHALL export `ReplayBuffer` with: `append(event: SseEvent)`, `since(lastEventId: string): readonly SseEvent[]`, `evictExpired(now: number)`. The buffer is per `(tenant, topic)` and bounded by `eventBufferSize` (default 256) and `eventBufferMs` (default 30000). `since` SHALL return every event whose id sorts AFTER `lastEventId` in append order. An unknown `lastEventId` (evicted or never seen) MUST surface `SSE.LAST_EVENT_ID_UNKNOWN` so the engine can fall back to live subscription.
#### Scenario: since(id) returns events after the marker
- **WHEN** events A, B, C are appended in order AND `since(B.id)` is called
- **THEN** the result is `[C]`
#### Scenario: unknown id surfaces error code
- **WHEN** `since("never-seen-id")` is called
- **THEN** the call throws `SSE.LAST_EVENT_ID_UNKNOWN`
#### Scenario: time-based eviction drops expired entries
- **WHEN** the fake clock advances past `eventBufferMs`
- **THEN** the expired entries are dropped on the next `evictExpired(now)`
**Verified by:** `src/replay-buffer/replay-buffer.test.ts::"since returns events after marker"` AND `src/replay-buffer/last-event-id.test.ts::"unknown lastEventId surfaces LAST_EVENT_ID_UNKNOWN"` AND `src/replay-buffer/replay-buffer.test.ts::"time-based eviction drops expired entries"`
---
### Requirement: Connection registry multi-index
The package SHALL export `SseConnectionRegistry` with O(1) lookup by `tenantId` and by `(topic, tenantId)`. `add`, `remove`, `lookupByTenant`, `lookupByTopic`, `countByTenant`. `remove` MUST be idempotent.
#### Scenario: lookup returns matching set
- **WHEN** 2 SSE connections subscribe to tenant T
- **THEN** `lookupByTenant("T").length === 2`
#### Scenario: remove is idempotent
- **WHEN** `remove(conn)` is called twice
- **THEN** the second call returns without throwing
**Verified by:** `src/connection-registry/connection-registry.test.ts::"lookupByTenant returns matching set"` AND `src/connection-registry/connection-registry.test.ts::"remove is idempotent"`
---
### Requirement: CapabilityId augmentation contributes "sse"
The package SHALL augment the `CapabilityId` union to include `"sse"`.
#### Scenario: sse is assignable to CapabilityId
- **WHEN** `expectTypeOf<"sse">().toMatchTypeOf<CapabilityId>()` is checked
- **THEN** the assertion passes
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"sse is a valid CapabilityId after augmentation"`
---
### Requirement: Error codes registry frozen
The package SHALL export `SSE_CODES` containing exactly: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BUFFER_OVERFLOW`, `LAST_EVENT_ID_UNKNOWN`, `CONNECTION_CAP_EXCEEDED`, `BROKER_UNAVAILABLE`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `SSE_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every SSE_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for engines
The package SHALL export `runSseEngineConformance({ factory, label })`. Every concrete SSE engine MUST pass.
#### Scenario: harness covers handshake, send, last-event-id replay, eviction
- **WHEN** the harness runs against a stub engine
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers handshake send replay eviction"`
---
### Requirement: Test timeouts use the options-object form
Every `it()` and `test()` call in this package's `*.test.ts` files SHALL pass `{ timeout: 5000 }` (or `{ timeout: 30000 }` for async-heavy tests) as the second argument, per CLAUDE.md §Quality. Tests SHALL NOT use the legacy trailing-number form (`it("...", () => {}, 5000)`) because that form is incompatible with the options-object form used by the rest of the monorepo and Vitest does not accept both simultaneously.
#### Scenario: every test declares an explicit timeout
- **WHEN** every `it()` or `test()` line in `packages/api-stack/api/api-sse/src/**/*.test.ts` is inspected
- **THEN** each one passes `{ timeout: <ms> }` as the second argument
**Verified by:** `bunx turbo lint test --filter=@suaveplan/api-sse` (the gate exercises every test under the runner)

View File

@@ -0,0 +1,145 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-tenant-provider-memory
**Package**: `@suaveplan/api-tenant-provider-memory`
**Status**: SHIPPED (2026-05-26)
## Requirements
### Requirement: Enumerate tenants from an in-memory store
The package SHALL implement `TenantProvider` from
`@suaveplan/api-tenant-provider/provider` using an insertion-ordered
`Map<TenantId, TenantRecord>` as the backing store. Initial state comes
from `createMemoryTenantProvider({ tenants? })`; runtime mutation flows
through out-of-band `add` / `remove` / `clear` admin helpers exposed on
the returned object.
#### Scenario: list yields active tenants in insertion order
- **WHEN** the provider is constructed with `tenants: [A_active, B_active, C_active]`
- **THEN** iterating `list()` yields A, B, C in that order
#### Scenario: list filters archived and conditionally surfaces suspended
- **WHEN** the seed contains active, suspended, and archived records
- **THEN** `list()` yields only active, `list({ includeSuspended: true })` yields active + suspended, and `archived` is never yielded by `list`
#### Scenario: lookup returns archived records
- **WHEN** `lookup(id)` is called for an `archived` tenant
- **THEN** the archived record is returned (not null)
#### Scenario: dispose is idempotent and clears state
- **WHEN** `dispose()` is called twice
- **THEN** the second call resolves without throwing AND subsequent `count()` returns 0 AND `lookup(any)` returns null
**Verified by:** `src/provider/provider.test.ts::"seeds in insertion order"` AND `src/provider/provider.test.ts::"count is active-only"` AND `src/provider/provider.test.ts::"lookup returns archived records"` AND `src/provider/provider.test.ts::"dispose drops state and is idempotent"` AND the `runTenantProviderConformance({ label: "memory", ... })` invocation in the same file.
---
### Requirement: Pagination via canonical cursor codec
`list()` SHALL honour `ListOpts.pageSize` (clamps the single iteration's
yield count) and `ListOpts.cursor` (resumes from a saved offset, using
the `encodeCursor` / `decodeCursor` codec shipped by
`@suaveplan/api-tenant-provider/pagination`). Invalid cursors raise
`TENANT_PROVIDER.INVALID_CURSOR` (from the abstract contract package),
not a memory-adapter code.
#### Scenario: pageSize limits a single iteration
- **WHEN** `list({ pageSize: 4 })` runs over a 10-tenant active seed
- **THEN** exactly 4 tenants are yielded
#### Scenario: cursor resumes from offset
- **WHEN** an `encodeCursor({ offset: 2, version: 1 })` is passed to `list()` over a 5-tenant active seed
- **THEN** the iteration yields tenants 2, 3, 4 only
#### Scenario: invalid cursor raises TENANT_PROVIDER.INVALID_CURSOR
- **WHEN** `list({ cursor: "not-a-cursor" })` is iterated
- **THEN** the iteration rejects with a `TenantProviderError` whose code is `TENANT_PROVIDER.INVALID_CURSOR`
**Verified by:** `src/provider/provider.test.ts::"pageSize limits one iteration"` AND `src/provider/provider.test.ts::"cursor resumes from offset"` AND the conformance harness invariant `"invalid cursor throws TENANT_PROVIDER.INVALID_CURSOR"`.
---
### Requirement: Boundary validation through tenantRecordSchema
Every record entering the backing store SHALL be parsed through
`tenantRecordSchema` from `@suaveplan/api-tenant-provider/tenant-record`.
As of the foundation-fix-gap-2-tenant-chain change, `tenantRecordSchema`
requires a non-empty `slug` and accepts an optional `metadata` bag —
callers MUST supply `slug` on every record entering the store. Schema
failures (including missing/empty `slug`) raise
`TENANT_PROVIDER_MEMORY.INVALID_RECORD`.
#### Scenario: seed with malformed record is rejected
- **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "", slug: "x", status: "active", ... }] })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
#### Scenario: seed with missing slug is rejected
- **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "t1", status: "active", createdAt }] })` (no slug) is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
#### Scenario: add() validates input
- **WHEN** `add({ id: "", slug: "x", ... })` is called with an empty id
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
#### Scenario: invalid options shape rejected
- **WHEN** `createMemoryTenantProvider({ tenants: "not-an-array" })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
**Verified by:** `src/provider/provider.test.ts::"rejects malformed seed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"add rejects malformed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"rejects invalid options shape"`.
---
### Requirement: Mutation admin helpers
The returned `MemoryTenantProvider` SHALL expose `add(record)`,
`remove(tenantId)`, and `clear()` as out-of-band admin helpers (not on
the abstract `TenantProvider` contract). `add` raises
`TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT` on a colliding id; `remove`
raises `TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND` for unknown ids.
`clear()` always succeeds.
#### Scenario: add rejects duplicate id
- **WHEN** the provider seed already contains `id="acme"` AND `add({ id: "acme", ... })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT`
#### Scenario: remove of unknown id
- **WHEN** `remove("nope")` is called against an empty provider
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND`
#### Scenario: seed with duplicate ids rejected
- **WHEN** `createMemoryTenantProvider({ tenants: [A, A] })` is called
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT`
**Verified by:** `src/provider/provider.test.ts::"add appends a new tenant"` AND `src/provider/provider.test.ts::"add rejects duplicate id (DUPLICATE_TENANT)"` AND `src/provider/provider.test.ts::"remove drops a tenant"` AND `src/provider/provider.test.ts::"remove of unknown id throws TENANT_NOT_FOUND"` AND `src/provider/provider.test.ts::"clear empties the store"` AND `src/provider/provider.test.ts::"rejects duplicate id in seed"`.
---
### Requirement: Conformance with the abstract contract
The package SHALL pass `runTenantProviderConformance` from
`@suaveplan/api-tenant-provider/testing` with `label: "memory"`.
#### Scenario: conformance suite is invoked at module-load time
- **WHEN** the test file imports and calls `runTenantProviderConformance({ label: "memory", factory: (seed) => createMemoryTenantProvider({ tenants: seed }) })`
- **THEN** every invariant of the abstract contract passes (8 cases)
**Verified by:** the top-level call in `src/provider/provider.test.ts`.
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,209 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-tenant-provider (api-tenant-provider)
**Package**: `@suaveplan/api-tenant-provider`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: TenantProvider contract
The package SHALL export `TenantProvider`: `list(opts?: ListOpts): AsyncIterable<TenantRecord>`, `count(): Promise<number>`, `lookup(tenantId: TenantId): Promise<TenantRecord | null>`, `dispose(): Promise<void>`. `lookup` MUST return `null` (not throw) when the tenant is not found.
#### Scenario: every method present
- **WHEN** `expectTypeOf<TenantProvider>()` is checked
- **THEN** all four methods are declared
#### Scenario: lookup returns null for missing tenant
- **WHEN** a stub provider with no tenants is given an arbitrary id
- **THEN** `lookup(id)` resolves to `null`
**Verified by:** `src/provider/provider.spec-conformance.test.ts::"TenantProvider declares list count lookup dispose"` AND `src/provider/provider.test.ts::"lookup returns null for missing tenant"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: TenantRecord schema
The package SHALL export `tenantRecordSchema: z.ZodSchema<TenantRecord>` describing `{ id: TenantId, slug: string, displayName?: string, status: "active" | "suspended" | "archived", createdAt: string, metadata?: Record<string, unknown> }`. `id` MUST be branded as `TenantId` (not plain string). `slug` MUST be a non-empty string mirroring `TenantContext.slug` so consumers can map an inventory row to a per-request context without synthesising the slug from the id. `metadata` mirrors `TenantContext.metadata` so adapter-extensible context can be carried end-to-end. Unknown keys MUST be rejected.
#### Scenario: minimal record parses
- **WHEN** `{ id: "t1", slug: "t1", status: "active", createdAt: "2026-05-13T..." }` is parsed
- **THEN** it succeeds AND `id` is typed as `TenantId` AND `slug` is preserved as a string
#### Scenario: unknown status rejected
- **WHEN** `{ id: "t1", slug: "t1", status: "weird", createdAt: "..." }` is parsed
- **THEN** Zod reports an issue at `status`
#### Scenario: missing slug rejected
- **WHEN** a record without a `slug` key is parsed
- **THEN** Zod reports an issue at `slug`
#### Scenario: empty slug rejected
- **WHEN** `{ id: "t1", slug: "", status: "active", createdAt: "..." }` is parsed
- **THEN** Zod reports an issue at `slug`
#### Scenario: metadata round-trips
- **WHEN** a record with `metadata: { tier: "gold" }` is parsed
- **THEN** `record.metadata.tier === "gold"`
**Verified by:** `src/tenant-record/tenant-record.test.ts::"parses a minimal active record"` AND `src/tenant-record/tenant-record.test.ts::"rejects unknown status"` AND `src/tenant-record/tenant-record.test.ts::"rejects record without slug"` AND `src/tenant-record/tenant-record.test.ts::"rejects empty slug"` AND `src/tenant-record/tenant-record.test.ts::"accepts a record with metadata"`
---
### Requirement: TenantResolverChain factory from a TenantProvider
The package SHALL export `tenantResolverChainFromProvider(provider, opts?)` from the `./chain` subpath (and re-export from the root barrel). The returned object MUST satisfy `TenantResolverChain` from `@suaveplan/api-types/tenant-resolver`.
Three built-in strategies SHALL be tried in fixed order, first-match-wins: (1) `X-Tenant-Id` header (configurable via `opts.headerName`), (2) JWT claim when `opts.jwtClaimExtractor` is supplied, (3) Subdomain when `opts.subdomainBase` is supplied. Each strategy resolves a tenant-id string, then calls `provider.lookup(id)` and maps the resulting `TenantRecord` to a `TenantContext` via `recordToContext`. When the lookup returns `null` the chain MUST advance; when no strategy hits the chain MUST resolve to `null`.
Optional per-tenant-id memoisation (`opts.cache: { maxEntries, ttlMs }`) collapses repeated inventory lookups; misses are also memoised.
#### Scenario: header strategy resolves via the default X-Tenant-Id header
- **WHEN** a request carries `X-Tenant-Id: t1` AND the provider has a record `{ id: "t1", slug: "acme", ... }`
- **THEN** the chain resolves to `{ id: "t1", slug: "acme" }`
#### Scenario: header wins over JWT when both present
- **WHEN** both the header and `jwtClaimExtractor` produce different ids
- **THEN** the header's record is returned
#### Scenario: subdomain strategy extracts a single-label tenant from host
- **WHEN** the chain is built with `subdomainBase: "example.com"` AND the request host is `acme.example.com`
- **THEN** the chain resolves with the record looked up by id `"acme"`
#### Scenario: chain returns null when no strategy hits
- **WHEN** the request has neither the header nor a matching subdomain nor a JWT claim
- **THEN** `chain.resolve(req)` resolves to `null`
#### Scenario: cached tenant lookups collapse to one provider call
- **WHEN** `{ cache: { maxEntries: 10, ttlMs: 60000 } }` is configured AND three requests for the same tenant id are resolved
- **THEN** `provider.lookup` is invoked exactly once
**Verified by:** `src/chain/chain.test.ts::"resolves via X-Tenant-Id header (default)"` AND `src/chain/chain.test.ts::"header wins over JWT when both present"` AND `src/chain/chain.test.ts::"extracts tenant from subdomain"` AND `src/chain/chain.test.ts::"returns null when header missing"` AND `src/chain/chain.test.ts::"memoises lookups for the same tenant id"`
---
### Requirement: recordToContext bridges TenantRecord to TenantContext
The package SHALL export `recordToContext(record: TenantRecord): TenantContext` from the `./chain` subpath. It MUST drop `status` and `createdAt`, preserve `id`/`slug`, and pass through optional `displayName`/`metadata` when present. It MUST NOT synthesise `slug` from `id` — slug comes off the record verbatim.
#### Scenario: drops status and createdAt
- **WHEN** `recordToContext({ id, slug, status, createdAt })` is called
- **THEN** the result is exactly `{ id, slug }`
#### Scenario: preserves displayName and metadata when present
- **WHEN** the record carries both `displayName` and `metadata`
- **THEN** both are present on the returned `TenantContext`
**Verified by:** `src/chain/chain.test.ts::"preserves id and slug, drops status and createdAt"` AND `src/chain/chain.test.ts::"preserves both displayName and metadata together"`
---
### Requirement: Status filter — active-by-default
`list(opts)` MUST default to enumerating only tenants whose `status === "active"`. `list({ includeSuspended: true })` MUST also enumerate `suspended` tenants. `archived` tenants are NEVER enumerated by `list` — they are only reachable via `lookup`.
#### Scenario: default excludes suspended and archived
- **WHEN** the provider holds [active, suspended, archived] AND `list()` is consumed
- **THEN** only the active tenant is yielded
#### Scenario: includeSuspended surfaces suspended
- **WHEN** `list({ includeSuspended: true })` is consumed
- **THEN** both active and suspended tenants are yielded; archived is NOT yielded
**Verified by:** `src/provider/status-filter.test.ts::"default list excludes suspended and archived"` AND `src/provider/status-filter.test.ts::"includeSuspended surfaces suspended but not archived"`
---
### Requirement: Pagination cursor round-trip
`ListOpts.cursor` MUST round-trip — consuming part of `list({ pageSize: 10 })`, capturing a cursor, and resuming with `list({ pageSize: 10, cursor })` MUST yield the remainder without duplication or omission. Invalid cursors MUST throw `TENANT_PROVIDER.INVALID_CURSOR`.
#### Scenario: cursor resumes without gap or overlap
- **WHEN** 25 tenants exist AND the caller pulls 10 then resumes with the cursor
- **THEN** the resumption yields exactly 15 tenants AND no duplicates
#### Scenario: invalid cursor rejected
- **WHEN** an arbitrary string is passed as cursor
- **THEN** `TENANT_PROVIDER.INVALID_CURSOR` is thrown
**Verified by:** `src/pagination/pagination.test.ts::"cursor resumes without gap or overlap"` AND `src/pagination/pagination.test.ts::"invalid cursor rejected"`
---
### Requirement: CapabilityId augmentation contributes "tenant.provider"
The package SHALL export the runtime constant `TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider"` and the type alias `TenantProviderCapabilityId = typeof TENANT_PROVIDER_CAPABILITY_ID`. The package SHALL NOT declare a widened `CapabilityId` type union (`type CapabilityId = BaseCapabilityId | TenantProviderCapabilityId`) — that widening alias is removed because `@suaveplan/api-types/capabilities` ships `"tenant.provider"` directly on the foundation `CapabilityIdRegistry`. The package SHALL re-export the canonical `CapabilityId` from `@suaveplan/api-types/capabilities` for source compatibility with consumers that previously imported the widened alias from `@suaveplan/api-tenant-provider/capability-id`.
Third-party packages adding their own capability id SHALL extend `CapabilityIdRegistry` via TypeScript declaration-merging rather than re-introducing widening aliases on the consumer side.
#### Scenario: tenant.provider assignable to CapabilityId without widening
- **WHEN** `TENANT_PROVIDER_CAPABILITY_ID` is assigned to a binding of type `CapabilityId` imported from `@suaveplan/api-types/capabilities`
- **THEN** the assignment type-checks without any local widening
#### Scenario: CapabilityId re-export remains source-compatible
- **WHEN** a consumer imports `type CapabilityId` from `@suaveplan/api-tenant-provider/capability-id`
- **THEN** the resolved type is structurally identical to `CapabilityId` from `@suaveplan/api-types/capabilities`
**Verified by:** `src/capability-id/capability-id.test.ts::"narrows as a string literal at the type level"` AND `src/capability-id/capability-id.test.ts::"widened CapabilityId accepts both base and tenant.provider"`
---
### Requirement: Error codes registry frozen
The package SHALL export `TENANT_PROVIDER_CODES` containing exactly: `TENANT_NOT_FOUND`, `SOURCE_UNAVAILABLE`, `INVALID_CURSOR`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `TENANT_PROVIDER_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every TENANT_PROVIDER_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for providers
The package SHALL export `runTenantProviderConformance({ factory, label })`. Every concrete provider (in-memory in Stage 4, DB-backed downstream) MUST pass it.
#### Scenario: harness covers list, count, lookup, status filter, pagination, dispose
- **WHEN** the harness runs against a stub provider
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers list count lookup status-filter pagination dispose"`

View File

@@ -0,0 +1,78 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-tenant-resolver-header (api-tenant-resolver-header)
**Package**: `@suaveplan/api-tenant-resolver-header`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Resolves tenant from configured header
The package SHALL expose `createHeaderTenantResolver({ headerName, validate, registry })` that reads `headerName` (default `X-Tenant-Id`) and returns `{ tenantId, source: "header" }` when the value validates AND (when `registry` is supplied) is a member of the tenant registry.
#### Scenario: well-formed header resolves
- WHEN a request carries `X-Tenant-Id: acme` AND `acme` is in the registry
- THEN the resolver returns `{ tenantId: "acme", source: "header" }` AND `api.tenant.resolves_total{resolver="header"}` increments
**Verified by:** `src/resolver/resolver.test.ts::"well-formed header resolves"`
---
### Requirement: Strict mode behaviour for invalid values
The package SHALL react to invalid header values per `strict` config: when `strict: true`, raise `TENANT_HEADER_INVALID` (HTTP 400). When `strict: false`, return `null` so a downstream resolver may match.
#### Scenario: strict raises 400
- WHEN the header value fails the validator AND `strict: true`
- THEN the resolver raises `TENANT_HEADER_INVALID`
#### Scenario: non-strict returns null
- WHEN the header value fails the validator AND `strict: false`
- THEN the resolver returns `null`
**Verified by:** `src/resolver/resolver.test.ts::"strict mode raises 400 on invalid"` AND `src/resolver/resolver.test.ts::"non-strict mode returns null on invalid"`
---
### Requirement: Trusted-source enforcement
The package SHALL ignore the header when `request.metadata.source` is not in `trustedSources` (default `["service-mesh"]`). When ignored AND `strict: true`, raise `TENANT_HEADER_UNTRUSTED_SOURCE`; when ignored AND `strict: false`, return `null`.
#### Scenario: untrusted source raises in strict
- WHEN the header is present AND `request.metadata.source = "edge"` AND `trustedSources = ["service-mesh"]` AND `strict: true`
- THEN the resolver raises `TENANT_HEADER_UNTRUSTED_SOURCE`
**Verified by:** `src/trust/trust.test.ts::"untrusted source raises in strict mode"`
---
### Requirement: Not-found-in-registry distinct from invalid
The package SHALL distinguish a tenant that fails validation (`TENANT_HEADER_INVALID`) from one that is well-formed but not in the registry (`TENANT_HEADER_NOT_FOUND_IN_REGISTRY`). Both surface in strict mode; both return `null` in non-strict mode.
#### Scenario: well-formed but unknown tenant
- WHEN the header is `X-Tenant-Id: unknown-tenant-1` AND the registry does not contain it AND `strict: true`
- THEN the resolver raises `TENANT_HEADER_NOT_FOUND_IN_REGISTRY`
**Verified by:** `src/resolver/resolver.test.ts::"well-formed but unknown raises NOT_FOUND_IN_REGISTRY in strict"`
---
### Requirement: Passes `runTenantResolverConformance`
The package SHALL pass the conformance harness exported by `@suaveplan/api-tenant-provider/testing`.
#### Scenario: conformance passes
- WHEN `runTenantResolverConformance("header", makeHeaderResolver)` runs
- THEN every harness `it(...)` passes
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts::"header resolver passes runTenantResolverConformance"`

View File

@@ -0,0 +1,63 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-tenant-resolver-jwt (api-tenant-resolver-jwt)
**Package**: `@suaveplan/api-tenant-resolver-jwt`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: Resolve tenant from Principal
The package SHALL implement `TenantResolver` such that `resolve(request, principal)` returns `principal.tenantId` when present, returns `undefined` (chain-advance sentinel) when principal is non-null but has no `tenantId`, and returns `undefined` when principal is null (unless `requirePrincipal: true`).
#### Scenario: principal with tenantId resolves directly
- **WHEN** `principal = { id: "u1", tenantId: "acme", scopes: [], claims: {} }`
- **THEN** `resolve()` returns `"acme"`
#### Scenario: principal without tenantId advances the chain
- **WHEN** `principal = { id: "u1", scopes: [], claims: {} }` (no tenantId)
- **THEN** `resolve()` returns `undefined` so the next resolver in the chain runs
#### Scenario: null principal advances the chain by default
- **WHEN** `principal === null` AND `requirePrincipal: false`
- **THEN** `resolve()` returns `undefined`
#### Scenario: requirePrincipal: true throws when principal is null
- **WHEN** `principal === null` AND `requirePrincipal: true`
- **THEN** `resolve()` throws `TENANT_JWT_PRINCIPAL_REQUIRED`
**Verified by:** `src/factory/create-jwt-tenant-resolver.test.ts::"returns principal.tenantId when present"` AND `src/factory/create-jwt-tenant-resolver.test.ts::"returns undefined when tenantId missing"` AND `src/factory/create-jwt-tenant-resolver.test.ts::"throws when requirePrincipal and principal is null"`
---
### Requirement: Direct and worker-proxy surfaces both pass conformance
#### Scenario: direct conforms
- **WHEN** `runTenantResolverConformance("jwt-direct", () => createJwtTenantResolver(...))` runs
- **THEN** every invariant passes
#### Scenario: worker-proxy conforms
- **WHEN** `runTenantResolverConformance("jwt-worker-proxy", ...)` runs over a real `MessageChannel`
- **THEN** every invariant passes
**Verified by:** `src/factory/create-jwt-tenant-resolver.spec-conformance.test.ts::"jwt-direct conforms to TenantResolver contract"` AND `src/proxy/create-jwt-tenant-resolver-worker-proxy.spec-conformance.test.ts::"jwt-worker-proxy conforms to TenantResolver contract"`
---
### Requirement: Options Zod-validated
#### Scenario: invalid options throw at construction
- **WHEN** `createJwtTenantResolver({ mode: "bogus" as never })`
- **THEN** a `SuaveplanError` with code `TENANT_JWT_INVALID_OPTIONS` is thrown synchronously
**Verified by:** `src/options/jwt-tenant-resolver-options.test.ts::"throws on invalid mode value"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,78 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-tenant-resolver-subdomain (api-tenant-resolver-subdomain)
**Package**: `@suaveplan/api-tenant-resolver-subdomain`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Resolves tenant from request host subdomain
The package SHALL expose `createSubdomainTenantResolver({ baseDomain, validate, registry })` that reads `request.host`, strips `baseDomain`, validates the remaining label, and returns `{ tenantId, source: "subdomain" }` when valid.
#### Scenario: leftmost label resolves as tenant
- WHEN `request.host = "acme.api.example.com"` AND `baseDomain = "api.example.com"`
- THEN the resolver returns `{ tenantId: "acme", source: "subdomain" }`
**Verified by:** `src/resolver/resolver.test.ts::"leftmost label resolves as tenant"`
---
### Requirement: Base-domain mismatch handled per strict mode
The package SHALL raise `TENANT_SUBDOMAIN_BASE_DOMAIN_MISMATCH` in strict mode when the host does not end with `baseDomain`. In non-strict mode it returns `null`.
#### Scenario: mismatched base domain raises in strict
- WHEN `request.host = "acme.evil.example.com"` AND `baseDomain = "api.example.com"` AND `strict: true`
- THEN the resolver raises `TENANT_SUBDOMAIN_BASE_DOMAIN_MISMATCH`
**Verified by:** `src/resolver/resolver.test.ts::"mismatched base domain raises in strict"`
---
### Requirement: Multi-label deployments via `tenantLabelIndex`
The package SHALL accept `tenantLabelIndex` (default 0) to support hosts like `<tenant>.<region>.api.example.com`.
#### Scenario: middle-label deployment
- WHEN `request.host = "acme.eu.api.example.com"` AND `tenantLabelIndex = 0` AND `baseDomain = "api.example.com"`
- THEN tenant is `acme` (label 0 of `acme.eu`)
**Verified by:** `src/resolver/resolver.test.ts::"multi-label deployment resolves by index"`
---
### Requirement: `X-Forwarded-Host` trust requires CIDR allowlist
The package SHALL only consult `X-Forwarded-Host` when `trustForwardedHost: true` AND the upstream IP matches `trustedProxyCidrs`. Untrusted upstream raises `TENANT_SUBDOMAIN_FORWARDED_HOST_UNTRUSTED` in strict mode.
#### Scenario: untrusted proxy ignored
- WHEN `X-Forwarded-Host` is set AND the upstream IP is not in `trustedProxyCidrs` AND `strict: true`
- THEN the resolver raises `TENANT_SUBDOMAIN_FORWARDED_HOST_UNTRUSTED`
#### Scenario: trusted proxy honoured
- WHEN the upstream IP matches `trustedProxyCidrs` AND `X-Forwarded-Host` is well-formed
- THEN the forwarded host is used to resolve the tenant
**Verified by:** `src/forwarded/forwarded.test.ts::"untrusted proxy raises FORWARDED_HOST_UNTRUSTED"` AND `src/forwarded/forwarded.test.ts::"trusted proxy honours forwarded host"`
---
### Requirement: Passes `runTenantResolverConformance`
The package SHALL pass `runTenantResolverConformance` from `@suaveplan/api-tenant-provider/testing`.
#### Scenario: conformance passes
- WHEN `runTenantResolverConformance("subdomain", makeSubdomainResolver)` runs
- THEN every harness `it(...)` passes
**Verified by:** `src/resolver/resolver.spec-conformance.test.ts::"subdomain resolver passes runTenantResolverConformance"`

View File

@@ -0,0 +1,171 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-tenant
**Package**: `@suaveplan/api-tenant`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: Canonical Tenant type + role + permissions
The package SHALL export a single `Tenant` interface (`{ id, name, slug, status, plan?, settings, enabledPlugins, domains, metadata?, createdAt, updatedAt }`) with `TenantStatus = "active" | "suspended" | "pending" | "deleted"`, `TenantRole = "owner" | "admin" | "member" | "viewer"`, `TenantRolePermissions` permission map, and `hasPermission(role, perm)` helper. Wildcard `"*"` on `owner` grants every permission.
#### Scenario: owner has every permission
- **WHEN** `hasPermission("owner", "any:string")` is called
- **THEN** the result is `true`
#### Scenario: viewer cannot update tenant
- **WHEN** `hasPermission("viewer", "tenant:update")` is called
- **THEN** the result is `false`
**Verified by:** `src/types/tenant.test.ts::"owner has wildcard permission"` AND `src/types/tenant.test.ts::"viewer cannot tenant:update"`
---
### Requirement: Six built-in resolver strategies + chained composition
The package SHALL ship `HeaderTenantResolver`, `SubdomainTenantResolver` (strips `www` + port), `DomainTenantResolver`, `PathTenantResolver`, `JWTTenantResolver` (reads `c.get("user")[claim]`), and `ChainedTenantResolver` (first non-null wins). All resolvers accept a caller-supplied `lookupFn` for the actual data lookup.
#### Scenario: subdomain resolver strips port and ignores www
- **WHEN** a `SubdomainTenantResolver("app.com", lookupFn)` resolves a request with `host: "foo.app.com:3000"`
- **THEN** `lookupFn` is called with `"foo"`
#### Scenario: chained resolver returns first non-null
- **WHEN** `ChainedTenantResolver(headerResolver, jwtResolver, subdomainResolver)` resolves a request where only the JWT resolver returns a tenant
- **THEN** the chain returns the JWT tenant and does not call the subdomain resolver
**Verified by:** `src/resolver/resolver.test.ts::"subdomain strips port and www"` AND `src/resolver/resolver.test.ts::"chained returns first non-null"` AND `src/resolver/resolver.test.ts::"jwt reads claim from c.get('user')"`
---
### Requirement: TenantCache prevents stampede with TTL + negative cache
`TenantCache.wrap(resolver, options)` SHALL return a new `TenantResolver` that caches positive lookups for `ttlMs` and negative lookups for `negativeTtlMs` (default smaller). Cache key is the underlying resolver's discriminating input (host/subdomain/path/header). Concurrent in-flight lookups for the same key SHALL share a single `Promise` (request coalescing).
#### Scenario: positive cache hit avoids second lookup
- **WHEN** the same tenant is resolved twice within `ttlMs` against a cached resolver
- **THEN** the underlying `lookupFn` is called exactly once
#### Scenario: negative cache prevents lookup stampede
- **WHEN** the same unmatched key is resolved 100× in parallel against a cached resolver
- **THEN** the underlying `lookupFn` is called exactly once
**Verified by:** `src/cache/cache.test.ts::"positive hit skips second lookup"` AND `src/cache/cache.test.ts::"negative hit prevents stampede"` AND `src/cache/cache.test.ts::"in-flight lookups coalesce"`
---
### Requirement: Tenant middleware sets one canonical Hono context key
`createTenantMiddleware(options)` SHALL set `c.set("tenant", tenant)` exactly once per request. It SHALL NOT set duplicate keys (`tenantId`, `tenantContext`). The package SHALL augment Hono's `ContextVariableMap` so `c.get("tenant")` returns `Tenant` with no cast. Status checks (`deleted` → `TENANT_NOT_FOUND`, `suspended` (unless `allowSuspended`) → `TENANT_SUSPENDED`, `pending` → `TENANT_PENDING`) SHALL raise typed errors.
#### Scenario: middleware sets exactly one context key
- **WHEN** a request resolves and middleware completes
- **THEN** `c.get("tenant")` returns the typed `Tenant`; `c.get("tenantId")` and `c.get("tenantContext")` are NOT set
#### Scenario: suspended tenant raises TENANT_SUSPENDED
- **WHEN** a resolver returns a suspended tenant and `allowSuspended` is false
- **THEN** the middleware throws `TenantError(TENANT_SUSPENDED)`
**Verified by:** `src/middleware/middleware.test.ts::"sets one c.get('tenant') key"` AND `src/middleware/middleware.test.ts::"suspended raises TENANT_SUSPENDED"` AND `src/middleware/middleware.test.ts::"deleted raises TENANT_NOT_FOUND"`
---
### Requirement: NullTenantContext is fail-closed
`NullTenantContext.isPluginEnabled(name)` SHALL return `false` for every name. `isFeatureEnabled` SHALL return `false`. `getLimit` SHALL return `undefined`. `getSetting` SHALL return the supplied default. This is a deliberate behaviour change from upstream (which returns `true`) to prevent silent permission grants on tenant-less requests.
#### Scenario: NullTenantContext denies every plugin
- **WHEN** `NullTenantContext.isPluginEnabled("payment")` is called
- **THEN** the result is `false`
**Verified by:** `src/context/context.test.ts::"NullTenantContext fail-closes plugin check"` AND `src/context/context.test.ts::"NullTenantContext fail-closes feature check"`
---
### Requirement: requirePlugin / requireFeature middleware fail closed
`requirePlugin(name)` and `requireFeature(name)` SHALL raise `TenantError(TENANT_NOT_FOUND)` when no tenant is resolved (paired with `NullTenantContext` fail-closed semantics).
#### Scenario: requirePlugin on tenant-less request raises
- **WHEN** `requirePlugin("payment")` runs on a request with no resolved tenant
- **THEN** the middleware throws `TenantError(TENANT_NOT_FOUND)`
**Verified by:** `src/middleware/require-plugin.test.ts::"raises when no tenant resolved"` AND `src/middleware/require-feature.test.ts::"raises when no tenant resolved"`
---
### Requirement: Pluggable tenantRateLimit store with bounded eviction
`tenantRateLimit({ maxRequests, windowMs, store? })` SHALL accept a pluggable `RateLimitStore` interface (`get(key)`, `set(key, value)`, `delete(key)`). The default `MemoryRateLimitStore` SHALL be bounded by `maxEntries` (default 10_000) with LRU eviction on overflow. Exceeding the request limit SHALL set `Retry-After` and respond 429 with `TenantError(TENANT_RATE_LIMITED)`.
#### Scenario: memory store evicts oldest beyond cap
- **WHEN** the in-memory store receives `maxEntries + 1` distinct keys
- **THEN** the oldest key is evicted (LRU)
#### Scenario: limit exceeded responds 429
- **WHEN** the same tenant exceeds `maxRequests` within `windowMs`
- **THEN** the response is 429 with `Retry-After` header set
**Verified by:** `src/rate-limit/rate-limit.test.ts::"memory store LRU evicts beyond maxEntries"` AND `src/rate-limit/rate-limit.test.ts::"exceeds limit responds 429 with Retry-After"` AND `src/rate-limit/rate-limit.test.ts::"pluggable store interface"`
---
### Requirement: TenantAwarePluginDescriptor supports tenantScopes opt-out
The package SHALL export `TenantAwarePluginDescriptor` extending `PluginDescriptor` from `@suaveplan/api-plugin` with `tenantScopes?: "all" | "explicit" | "none"` (default `"all"`). When `"none"`, the registered tenant lifecycle hooks SHALL not fire for that plugin. When `"explicit"`, only tenants whose `enabledPlugins` includes the plugin name receive the hook calls.
#### Scenario: tenantScopes: "none" skips the plugin
- **WHEN** a plugin declares `tenantScopes: "none"` and a tenant is created
- **THEN** the plugin's `onTenantCreate` is NOT called
#### Scenario: tenantScopes: "explicit" filters by enabledPlugins
- **WHEN** a plugin with `tenantScopes: "explicit"` registers `onTenantCreate` and a tenant whose `enabledPlugins` does not include the plugin is created
- **THEN** the plugin's `onTenantCreate` is NOT called
**Verified by:** `src/lifecycle/lifecycle.test.ts::"tenantScopes none skips hook"` AND `src/lifecycle/lifecycle.test.ts::"tenantScopes explicit filters by enabledPlugins"`
---
### Requirement: AsyncLocalStorage is opt-in
The package SHALL ship `withTenantStorage(enabled: boolean)` builder option. When `enabled=true`, the middleware wraps `next()` in `tenantStorage.run(tenant, next)` so `getCurrentTenant()` is available deep in the call stack. When `enabled=false` (default), no ALS scope is created and `getCurrentTenant()` always returns `undefined`. This eliminates the upstream dual-access-path footgun where `c.get("tenant")` and `getCurrentTenant()` could disagree.
#### Scenario: ALS off → getCurrentTenant is undefined
- **WHEN** `withTenantStorage(false)` is set and a request resolves a tenant
- **THEN** `getCurrentTenant()` called from any depth returns `undefined`
**Verified by:** `src/storage/storage.test.ts::"ALS off keeps getCurrentTenant undefined"` AND `src/storage/storage.test.ts::"ALS on threads tenant through async stack"`
---
### Requirement: Every TENANT_* code is thrown from a production path
`TENANT_CODES` SHALL define exactly 7 codes (`TENANT_NOT_FOUND`, `TENANT_SUSPENDED`, `TENANT_PENDING`, `TENANT_RESOLUTION_FAILED`, `TENANT_RATE_LIMITED`, `TENANT_INVALID_INPUT`, `TENANT_LOOKUP_TIMEOUT`). Every code SHALL be raised by at least one production call site.
**Verified by:** `src/api-tenant.spec-conformance.test.ts::"every TENANT_* code is thrown from a production path"`
---
### Requirement: Server-tier package shape
The package SHALL run in Bun + Node (server-tier — uses Hono types and `node:async_hooks`). `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage.
**Verified by:** `tests/dep-graph.test.ts::"dependencies match the server allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View 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"`

View File

@@ -0,0 +1,106 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-worker-host (api-worker-host)
**Package**: `@suaveplan/api-worker-host`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Worker hosts exactly one plugin
The package SHALL provide a worker-boot script that loads exactly ONE plugin module per Worker thread. The script SHALL read `workerData` for `{ manifest, moduleSpecifier, controlPort, capabilityPorts }`, import the module via `await import(moduleSpecifier)`, validate that the module's default export is a `PluginModule` produced by `definePlugin`, and emit a `WorkerReady` envelope over `controlPort`. Multiple plugins per worker MUST be rejected with `WORKER_BOOT_FAILED`.
#### Scenario: trivial plugin module boots and signals ready
- **WHEN** the simulator is started with a one-route plugin module
- **THEN** the `WorkerReady` envelope is observed on the control port within 100ms
#### Scenario: module without `definePlugin` default export rejects
- **WHEN** the simulator boots with a module whose default export is a plain object
- **THEN** the boot phase throws `WORKER_BOOT_FAILED` AND the worker exits non-zero
**Verified by:** `src/worker-boot/worker-boot.test.ts::"trivial plugin module boots and signals ready"` AND `src/worker-boot/worker-boot.test.ts::"non-definePlugin export rejects with WORKER_BOOT_FAILED"`
---
### Requirement: Lifecycle commands dispatch to plugin hooks over the control port
The package SHALL receive lifecycle command envelopes (`install`, `start`, `stop`, `uninstall`, `reload`) over the dedicated control port. Each command SHALL dispatch to the corresponding hook on the loaded plugin with a freshly-constructed `PluginContext` whose capability slots are RPC proxies wired in `worker-boot`. Hook outcomes SHALL be reported back: success → `LifecycleAck{ok}`, `SuaveplanError` → `LifecycleAck{error}` with the error reconstructed via `api-worker-rpc`'s error-reconstruction, anything else → wrapped in `WORKER_REMOTE_THROW`.
#### Scenario: install hook runs and acks success
- **WHEN** the receiver dispatches `install` AND the plugin's `install` hook returns
- **THEN** a `LifecycleAck{ok: true}` envelope is sent back
#### Scenario: stop hook throw is reconstructed
- **WHEN** the plugin's `stop` hook throws `SuaveplanError("PLUGIN.X.Y")`
- **THEN** the ack envelope carries an error with `code === "PLUGIN.X.Y"`
**Verified by:** `src/lifecycle-receiver/lifecycle-receiver.test.ts::"install hook runs and acks"` AND `src/lifecycle-receiver/lifecycle-receiver.test.ts::"stop hook throw is reconstructed"`
---
### Requirement: Uncaught exceptions surface as WORKER_CRASHED and exit non-zero
The package SHALL install `process.on("uncaughtException")` and `process.on("unhandledRejection")` handlers. Each handler SHALL: (a) emit a `WORKER_CRASHED` envelope over the control port carrying the original `name`, `message`, and stack as `details`, (b) flush the port, (c) call `process.exit(1)`. Silent crashes MUST NOT be possible — main-host's `LifecycleDriver` relies on these envelopes to transition plugins to the `crashed` state.
#### Scenario: uncaught exception emits envelope before exit
- **WHEN** the simulator throws an uncaught exception inside a handler
- **THEN** a `WORKER_CRASHED` envelope is observed on the control port AND the worker process exits with code 1
**Verified by:** `src/crash-detector/crash-detector.test.ts::"uncaught exception emits WORKER_CRASHED envelope"`
---
### Requirement: Heap / CPU / wall-clock budgets trigger controlled exit
The package SHALL sample `process.memoryUsage().heapUsed` at a configurable interval (default 1s) and reject when it exceeds `host.config.worker.heapLimitBytes` with a `WORKER_HEAP_LIMIT` envelope followed by `process.exit(1)`. The package SHALL track CPU consumption via `performance.now()` deltas across handler invocations and trigger `WORKER_CPU_BUDGET` when the rolling window exceeds the configured budget. The package SHALL enforce per-hook wall-clock budgets — a `start` / `stop` / `install` / `uninstall` hook that exceeds its budget MUST surface `WORKER_WALL_CLOCK_BUDGET` AND the hook's promise MUST reject with `AbortError`.
#### Scenario: heap-limit exceeded triggers WORKER_HEAP_LIMIT
- **WHEN** the simulator allocates past `heapLimitBytes`
- **THEN** a `WORKER_HEAP_LIMIT` envelope is observed AND the worker exits
#### Scenario: wall-clock-budget exceeded aborts the hook
- **WHEN** a `start` hook configured with `wallClockBudgetMs: 100` runs for 500ms
- **THEN** the hook's promise rejects with `AbortError` AND `WORKER_WALL_CLOCK_BUDGET` is sent
**Verified by:** `src/budget-guard/budget-guard.test.ts::"heap limit triggers WORKER_HEAP_LIMIT"` AND `src/budget-guard/budget-guard.test.ts::"wall-clock budget aborts hook with AbortError"`
---
### Requirement: Request dispatcher runs handler under plugin AbortSignal
The package SHALL accept marshalled request envelopes (the validated `body` / `params` / `query` per design doc §6, NOT the raw `Request`). It SHALL construct a `RequestContext` with the plugin's capability proxies + a fresh per-request `AbortSignal`, run the route handler, and serialise the response back. Main-side cancellation of the request MUST propagate through `api-worker-rpc`'s abort wiring and fire the request's `AbortSignal`.
#### Scenario: handler runs with validated body in context
- **WHEN** a dispatcher receives an envelope with `body: { name: "x" }`
- **THEN** the handler observes `ctx.request.body.name === "x"` AND a response envelope is sent back
#### Scenario: main-side cancel fires handler abort signal
- **WHEN** main sends an `RpcAbort` mid-dispatch
- **THEN** the handler's `ctx.abortSignal.aborted` becomes `true`
**Verified by:** `src/request-dispatcher/request-dispatcher.test.ts::"handler runs with validated body in context"` AND `src/request-dispatcher/request-dispatcher.test.ts::"main-side cancel fires AbortSignal"`
---
### Requirement: Plugin code cannot distinguish trusted from isolated tier
The package SHALL construct a `PluginContext` whose surface is structurally identical to the one a `trusted`-tier plugin receives directly from `api-host`. The capability slots SHALL be RPC proxies but their method names, return-type wrapping (always `Promise`), and field set MUST match exactly. A plugin author SHALL be able to write `definePlugin({ ... })` once and have it run under any tier without code change.
#### Scenario: structurally-identical PluginContext
- **WHEN** the same plugin module is run under the simulator AND under a direct in-process construction
- **THEN** `Object.keys(ctx)` is identical AND every capability slot has the same method names
**Verified by:** `tests/integration/real-worker.test.ts::"plugin context surface matches direct construction"`

View File

@@ -0,0 +1,137 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-worker-rpc (api-worker-rpc)
**Package**: `@suaveplan/api-worker-rpc`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Request/response correlation via monotonic message ids
The package SHALL assign every `RpcRequest` a monotonically-increasing `bigint` id from a per-port counter. Responses SHALL include the same id; the caller's response-table lookup MUST match by exact id. A response whose id has no outstanding entry MUST be rejected with `RPC_ORPHAN_RESPONSE` (not silently dropped — orphans indicate a protocol bug worth surfacing).
#### Scenario: ids are strictly monotonic per port
- **WHEN** 1000 requests are issued on one port
- **THEN** the issued ids form a strictly-increasing sequence with no gaps
#### Scenario: orphan response is rejected loudly
- **WHEN** a response arrives whose id has no outstanding entry
- **THEN** the callee logs `RPC_ORPHAN_RESPONSE` AND no caller promise is settled by it
**Verified by:** `src/correlation/correlation.test.ts::"ids are strictly monotonic per port"` AND `src/correlation/correlation.test.ts::"orphan response throws RPC_ORPHAN_RESPONSE"`
---
### Requirement: AbortSignal forwarding cancels in-flight calls
The package SHALL accept an optional `AbortSignal` per `createRpcCaller` call. When the caller's signal aborts, the protocol MUST send an `RpcAbort` message and the in-flight caller promise MUST reject with `AbortError` (DOMException, name `"AbortError"`). The callee MUST surface a fresh `AbortSignal` to its implementation function so user code can react. Aborting after settlement MUST be a no-op.
#### Scenario: caller abort rejects the promise
- **WHEN** a caller issues a request with `signal: ac.signal` AND calls `ac.abort()` before the response arrives
- **THEN** the promise rejects with an `AbortError`
#### Scenario: abort after settle is a no-op
- **WHEN** a request has already resolved AND `ac.abort()` is called
- **THEN** no error is thrown AND no `RpcAbort` is sent
**Verified by:** `src/abort/abort.test.ts::"caller abort rejects in-flight call"` AND `src/abort/abort.test.ts::"abort after settle is no-op"`
---
### Requirement: Streaming via Transferable ReadableStream
The package SHALL marshal `ReadableStream` arguments and return values via `Transferable` so chunks transfer zero-copy. The protocol SHALL carry per-chunk `ack` so the producing side cannot outrun the consumer; missing acks within a configurable window count against backpressure budget. Consumer cancellation MUST propagate back to the producer.
#### Scenario: large stream transfers in chunks
- **WHEN** a 64 MiB stream is sent across the port
- **THEN** the receiver observes the full byte sequence AND the producer is throttled by backpressure ack
#### Scenario: consumer cancel propagates to producer
- **WHEN** the receiver cancels the stream mid-transfer
- **THEN** the producer's stream receives a cancellation signal
**Verified by:** `src/streaming/streaming.test.ts::"large stream transfers in chunks"` AND `src/streaming/streaming.test.ts::"consumer cancel propagates to producer"`
---
### Requirement: SuaveplanError reconstruction across the wire
The package SHALL reconstruct `SuaveplanError` on the receiving side with the same `code`, the same `details` (Zod-validated), and a depth-bounded `cause` chain (default depth 5). Throws that are NOT `SuaveplanError` MUST be wrapped on the receiving side in a `SuaveplanError` with code `RPC_REMOTE_THROW` carrying the original `name` and `message` in `details`. The original stack is preserved as a `details.remoteStack` string field — never as a live `Error.stack` (which would not survive structured-clone).
#### Scenario: SuaveplanError round-trips with cause
- **WHEN** the callee throws `new SuaveplanError("PLUGIN.X.Y", { cause: new SuaveplanError("PLUGIN.X.Z") })`
- **THEN** the caller receives a `SuaveplanError` with `code === "PLUGIN.X.Y"` AND `(err.cause as SuaveplanError).code === "PLUGIN.X.Z"`
#### Scenario: plain Error becomes RPC_REMOTE_THROW
- **WHEN** the callee throws `new TypeError("boom")`
- **THEN** the caller receives `SuaveplanError` with code `RPC_REMOTE_THROW` AND `details.remoteName === "TypeError"`
**Verified by:** `src/error-reconstruction/error-reconstruction.test.ts::"SuaveplanError round-trips with cause"` AND `src/error-reconstruction/error-reconstruction.test.ts::"plain Error wraps as RPC_REMOTE_THROW"`
---
### Requirement: Backpressure rejects new calls before memory exhaustion
The package SHALL enforce a configurable bounded outstanding-message budget per port (default 1024). When the budget is exhausted, new `createRpcCaller` method calls MUST throw `RPC_BACKPRESSURE` synchronously at the call site — they MUST NOT queue indefinitely or allocate without bound. Acks AND response receipts return capacity.
#### Scenario: budget exceeded throws synchronously
- **WHEN** 1025 simultaneous calls are issued against a budget of 1024
- **THEN** the 1025th call throws `RPC_BACKPRESSURE` synchronously (no microtask delay)
**Verified by:** `src/backpressure/backpressure.test.ts::"budget exceeded throws RPC_BACKPRESSURE synchronously"`
---
### Requirement: Cloneable guard rejects non-structured-clone values at call site
The package SHALL pre-check arguments and return values for structured-clone safety before sending. Values that are NOT structured-clone-safe (functions, class instances without an explicit `serialize` hook, `Symbol`s) MUST throw `RPC_NOT_CLONEABLE` synchronously at the call site, naming the offending path (e.g. `"args[0].onCallback"`). Silent corruption of the wire MUST NOT be possible.
#### Scenario: function argument throws at call site
- **WHEN** `caller.method({ onProgress: () => {} })` is invoked
- **THEN** the call throws `RPC_NOT_CLONEABLE` synchronously AND `details.path === "args[0].onProgress"`
#### Scenario: cloneable nested object passes
- **WHEN** `caller.method({ a: { b: { c: 1 } } })` is invoked with a deeply-nested plain object
- **THEN** the call proceeds without error
**Verified by:** `src/cloneable-guard/cloneable-guard.test.ts::"function argument throws RPC_NOT_CLONEABLE"` AND `src/cloneable-guard/cloneable-guard.test.ts::"cloneable nested object passes"`
---
### Requirement: Caller type wraps every return type in Promise
The package SHALL expose `createRpcCaller<T>(port): WorkerProxyOf<T>`. The proxy's method signatures SHALL match `WorkerProxyOf<T>` from `api-types/worker-rpc`: every return type wrapped in `Promise<R>`, every method name preserved, non-cloneable parameter types flagged via the type system.
#### Scenario: every method's return type is Promise-wrapped
- **WHEN** `expectTypeOf<ReturnType<Caller<T>["someMethod"]>>()` is checked
- **THEN** it equals `Promise<R>` where `R` is the original return type
**Verified by:** `src/caller/caller.spec-conformance.test.ts::"every method return is Promise-wrapped"`
---
### Requirement: Wire protocol versions explicitly with mismatch rejection
The package SHALL embed a `protocolVersion` string (semver) in every `RpcRequest` and `RpcResponse`. Receivers MUST reject messages whose version is outside the supported range with `RPC_PROTOCOL_VERSION_MISMATCH`. The version is fixed at `"1.0.0"` for this Stage-3 ship; future incompatible changes MUST bump the major.
#### Scenario: mismatched protocol version rejects
- **WHEN** a message arrives with `protocolVersion: "2.0.0"`
- **THEN** the receiver throws `RPC_PROTOCOL_VERSION_MISMATCH` AND no impl method runs
**Verified by:** `tests/conformance/round-trip.test.ts::"mismatched protocol version is rejected"`

View File

@@ -0,0 +1,156 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-ws-broker-in-memory
**Package**: `@suaveplan/api-ws-broker-in-memory`
**Status**: SHIPPED 2026-05-26 (Stage 5 — Testing kit + conformance suites)
## Purpose
Single-host in-process implementation of the `@suaveplan/api-ws`
`WsBroker` contract. Maintains a per-topic `Set<handler>` registry and
fans `publish(topic, envelope)` out to every currently-subscribed
handler. Production-grade for single-host deployments AND the default
broker for `createTestHost` (Stage 5). Multi-host topologies use
`@suaveplan/api-ws-broker-redis` (Stage 7) over the same contract.
## ADDED Requirements
### Requirement: Fan-out to every subscriber on publish
The broker SHALL deliver `publish(topic, envelope)` to every handler
currently subscribed to `topic`. Delivery order across subscribers is
unspecified; per-subscriber message order is FIFO.
#### Scenario: every subscriber receives every message in publish order
- **WHEN** three handlers subscribe to `"orders"` AND two envelopes
are published in sequence
- **THEN** each handler receives both envelopes in publish order
**Verified by:** `src/broker/broker.test.ts::"fans out a single envelope to every subscriber"`
---
### Requirement: Subscription lifecycle (subscribe / unsubscribe / dispose)
The broker SHALL expose `subscribe(topic, handler)` returning a
`BrokerSubscription` whose `unsubscribe()` removes the handler. The
broker SHALL expose `dispose()` which clears every registered handler
and refuses subsequent `publish` / `subscribe` calls.
#### Scenario: unsubscribe stops delivery
- **WHEN** a handler subscribes, then unsubscribes, then a new
envelope is published
- **THEN** the handler is NOT invoked
#### Scenario: unsubscribe is idempotent
- **WHEN** `BrokerSubscription.unsubscribe()` is called twice
- **THEN** the second call is a no-op (no throw)
#### Scenario: dispose is idempotent
- **WHEN** `broker.dispose()` is called twice
- **THEN** the second call is a no-op and `isDisposed()` returns `true`
**Verified by:** `src/broker/broker.test.ts::"unsubscribe stops further delivery"`, `src/broker/broker.test.ts::"unsubscribe is idempotent"`, `src/broker/broker.test.ts::"dispose is idempotent"`
---
### Requirement: Use-after-dispose raises BROKER_DISPOSED
When `publish` or `subscribe` is invoked after `dispose()` has
resolved, the broker SHALL throw a
`WsBrokerInMemoryError` carrying the
`WS_BROKER_IN_MEMORY.BROKER_DISPOSED` code.
#### Scenario: publish after dispose throws
- **WHEN** `broker.dispose()` is called then `broker.publish(...)` is
awaited
- **THEN** the call throws `WS_BROKER_IN_MEMORY.BROKER_DISPOSED`
#### Scenario: subscribe after dispose throws
- **WHEN** `broker.dispose()` is called then `broker.subscribe(...)`
is called
- **THEN** the call throws `WS_BROKER_IN_MEMORY.BROKER_DISPOSED`
**Verified by:** `src/broker/broker.test.ts::"dispose throws BROKER_DISPOSED on subsequent publish/subscribe"`
---
### Requirement: Invalid options raise INVALID_OPTIONS
`createInMemoryWsBroker(options)` SHALL validate `options` against
`inMemoryWsBrokerOptionsSchema` (strict-mode Zod). Any failure SHALL
throw a `WsBrokerInMemoryError` carrying
`WS_BROKER_IN_MEMORY.INVALID_OPTIONS`.
#### Scenario: unknown options key throws
- **WHEN** `createInMemoryWsBroker({ unknown: true })` is called
- **THEN** the call throws `WS_BROKER_IN_MEMORY.INVALID_OPTIONS`
#### Scenario: invalid deliveryMode value throws
- **WHEN** `createInMemoryWsBroker({ deliveryMode: "blocking" })` is
called
- **THEN** the call throws `WS_BROKER_IN_MEMORY.INVALID_OPTIONS`
**Verified by:** `src/broker/broker.test.ts::"invalid options throws INVALID_OPTIONS"`, `src/broker/broker.test.ts::"invalid deliveryMode value throws INVALID_OPTIONS"`
---
### Requirement: Delivery mode semantics
`options.deliveryMode: "await-all"` SHALL await every handler before
`publish` resolves. `"fire-and-forget"` (default) SHALL dispatch
handlers without awaiting them. Per-subscriber failures in either
mode SHALL be swallowed individually and SHALL NOT reject the outer
publish nor abort sibling deliveries.
#### Scenario: await-all blocks publish until every handler settles
- **WHEN** `deliveryMode: "await-all"` AND two handlers each return a
delayed Promise
- **THEN** `publish` resolves only after both promises settle
#### Scenario: fire-and-forget isolates a throwing handler
- **WHEN** one handler throws synchronously AND another is correct
- **THEN** the correct handler still runs AND `publish` resolves
cleanly
**Verified by:** `src/broker/broker.test.ts::"isolates handler rejections in fire-and-forget mode"`, `src/broker/broker.test.ts::"isolates handler rejections in await-all mode"`
---
### Requirement: Conforms to WsBroker type contract
#### Scenario: type-shape matches WsBroker
- **WHEN** `expectTypeOf(broker)` is compared to `WsBroker<Payload>`
- **THEN** the comparison passes — every method declared on the
abstract contract exists with a compatible signature
**Verified by:** `tests/conformance/api-ws-broker.conformance.test.ts::"type-shape matches WsBroker"`
---
### Requirement: Testing harness
A `./testing` subpath SHALL export `createInMemoryBrokerHarness` that
wraps the broker with a captured-envelope observer, a `publishOne`
convenience, and an idempotent `dispose()` for use in `afterEach`.
#### Scenario: harness captures every published envelope
- **WHEN** `harness.publishOne(payload)` is called twice
- **THEN** `harness.received` contains both envelopes in publish order
**Verified by:** `src/testing/testing.test.ts::"captures every envelope published on the default topic"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,131 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-ws-broker-redis
**Package**: `@suaveplan/api-ws-broker-redis`
**Status**: SHIPPED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: Implements the WsBroker contract
The package SHALL export `createRedisWsBroker(opts)` producing a broker value implementing the `WsBroker` contract from `@suaveplan/api-ws`. The conformance suite from `@suaveplan/api-ws/testing` MUST pass against this broker.
#### Scenario: conformance suite passes
- **WHEN** the conformance suite runs against `createRedisWsBroker(opts)` backed by a real Redis container
- **THEN** every invariant passes
**Verified by:** `src/broker/redis-broker.spec-conformance.test.ts::"createRedisWsBroker passes WsBroker conformance against real Redis"`
---
### Requirement: Cross-host broadcast via Redis pub/sub
`broker.broadcast(topic, { tenant, payload })` SHALL publish to `<channelPrefix><topic>` on Redis. Every host subscribed to the channel pattern MUST receive the message and deliver it to its locally-connected subscribers via the local `ConnectionRegistry`. Connections are NOT migrated across hosts — the broker only makes broadcasts visible everywhere, and each host independently delivers to its own subscribers.
#### Scenario: broadcast from host A is delivered to subscribers on host B
- **WHEN** two `api-ws-bun` engines run on different processes pointed at the same Redis AND a client C subscribed on host B AND host A issues `broker.broadcast(topic, { tenant: T, payload: ... })` with C's tenant
- **THEN** C receives the payload
#### Scenario: sticky sessions are NOT required
- **WHEN** a client connects to host A AND later reconnects and lands on host B
- **THEN** subscriptions resume on host B from the event buffer with no requirement on routing back to host A
**Verified by:** `tests/integration/two-host-broadcast.test.ts::"broadcast from host A delivered to subscriber on host B"` AND `tests/integration/two-host-broadcast.test.ts::"reconnect lands on different host and resumes from event buffer"`
---
### Requirement: Reconnect event-buffer storage in Redis sorted-sets
The broker SHALL maintain a per-`(tenant, topic)` event buffer in Redis sorted-sets: `<keyPrefix>buffer:<tenant>:<topic>`, scored by `lastEventId`. Each entry SHALL be capped at `eventBufferMaxEvents` (default 256, oldest evicted on overflow) AND TTL'd at `eventBufferTtlSec` (default 300s). On reconnect, `api-ws-bun` calls `broker.readBuffer(tenant, topic, sinceEventId)` to replay events; SSE calls the same surface for `Last-Event-Id` resume.
#### Scenario: buffer cap evicts oldest entries on overflow
- **WHEN** `eventBufferMaxEvents: 4` AND 5 events are written for the same `(tenant, topic)`
- **THEN** the buffer holds the 4 most recent events AND the oldest was evicted
#### Scenario: TTL expires the buffer
- **WHEN** no writes for `eventBufferTtlSec + 1`
- **THEN** the buffer key has been removed from Redis
#### Scenario: replay returns events with id > sinceEventId
- **WHEN** the buffer holds events 1..10 AND `readBuffer(tenant, topic, 7)` is called
- **THEN** events 8, 9, 10 are returned in that order
**Verified by:** `src/event-buffer/event-buffer.test.ts::"buffer cap evicts oldest on overflow"` AND `src/event-buffer/event-buffer.test.ts::"buffer TTL expires keys"` AND `src/event-buffer/event-buffer.test.ts::"readBuffer returns events with id greater than sinceEventId"`
---
### Requirement: Payload compression above a configurable threshold
When a payload exceeds `compressionThresholdBytes` (default 1024), the broker SHALL gzip the payload before publishing. Subscribers SHALL gunzip on receive transparently. A payload that exceeds `4_194_304` bytes (4 MiB) hard cap MUST be refused with `WS_BROKER_REDIS_PAYLOAD_TOO_LARGE` — operators see the misuse instead of silently flooding Redis.
#### Scenario: payload above threshold is compressed
- **WHEN** a 2 KiB payload is broadcast
- **THEN** the bytes published to Redis are gzip-prefixed AND the subscriber receives the decompressed payload identical to input
#### Scenario: payload above 4 MiB hard cap is refused
- **WHEN** a 5 MiB payload is broadcast
- **THEN** the call fails with `WS_BROKER_REDIS_PAYLOAD_TOO_LARGE` AND nothing is published
**Verified by:** `src/publish/publish.test.ts::"payload above threshold is gzip-compressed end-to-end"` AND `src/publish/publish.test.ts::"payload above hard cap refused with WS_BROKER_REDIS_PAYLOAD_TOO_LARGE"`
---
### Requirement: Reconnect after Redis bounce
If the underlying Redis connection drops, the broker SHALL attempt auto-reconnect per `reconnectStrategy` (exponential backoff default). During the outage, `healthz()` returns `degraded`; in-flight publishes either queue (for short outages, configurable) or fail with `WS_BROKER_REDIS_CONNECTION_LOST`. On reconnect, pattern subscriptions MUST be re-established AND `ws.broker.connection_lost_total` reflects the outage count.
#### Scenario: short Redis bounce recovers transparently
- **WHEN** Redis is restarted (under 5 seconds) AND publishes continue
- **THEN** publishes resume after reconnect AND `ws.broker.connection_lost_total` increments by 1
#### Scenario: healthz reflects connection state
- **WHEN** Redis is unreachable
- **THEN** `broker.healthz()` returns `{ status: "degraded" | "down" }`
**Verified by:** `src/reconnect/reconnect.test.ts::"short Redis bounce recovers transparently and counter increments"` AND `src/health/health.test.ts::"healthz reflects Redis connection state"`
---
### Requirement: Same broker serves SSE and WebSocket
The broker SHALL be consumable by both `api-ws-bun` (WebSocket) and `api-sse-bun` (SSE). Both engines use `broker.broadcast` for fan-out AND `broker.readBuffer` for reconnect resume. The broker MUST NOT assume one transport over the other.
#### Scenario: SSE engine and WS engine share the same broker
- **WHEN** an integration test boots `api-sse-bun` and `api-ws-bun` against the same broker AND a broadcast fires
- **THEN** both engines deliver to their respective subscribers
**Verified by:** `tests/integration/two-host-broadcast.test.ts::"single broker serves both WS and SSE engines"`
---
### Requirement: OTel surface
The broker SHALL emit:
- `ws.broker.publishes_total` counter `{ topic, outcome }`.
- `ws.broker.publish_duration_ms` histogram.
- `ws.broker.receives_total` counter `{ topic }`.
- `ws.broker.buffer_writes_total` counter.
- `ws.broker.buffer_reads_total` counter `{ hit }` (`"hit"` or `"miss"` based on whether events were returned).
- `ws.broker.connection_lost_total` counter.
#### Scenario: successful publish emits counter + histogram
- **WHEN** a publish completes successfully
- **THEN** `ws.broker.publishes_total{outcome="ok"}` increments AND one observation lands in `ws.broker.publish_duration_ms`
**Verified by:** `src/instruments/instruments.test.ts::"successful publish emits publishes_total + publish_duration_ms"`

View File

@@ -0,0 +1,167 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-ws-bun (api-ws-bun)
**Package**: `@suaveplan/api-ws-bun`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: Implements the abstract WebSocket engine contract
The package SHALL export `createBunWsEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-ws`. The conformance suite `runWsEngineConformance` MUST pass against this adapter.
#### Scenario: conformance suite passes
- **WHEN** `runWsEngineConformance("bun", createBunWsEngine)` runs
- **THEN** every invariant passes
**Verified by:** `src/engine/bun-ws-engine.spec-conformance.test.ts::"createBunWsEngine passes runWsEngineConformance"`
---
### Requirement: Handshake freezes the principal for the connection lifetime
At handshake the engine SHALL run per-request pipeline steps 1–7 (auth + tenant resolution + capability scoping) and bind the resolved `principal` and `tenantId` to the `Connection`. The principal MUST be immutable for the connection's lifetime. Mid-connection token expiry MUST emit `connection.principal_expired` on the host event bus; the plugin's `onClose` MUST then be invokable to drop the connection cleanly. `WS_BUN_PRINCIPAL_EXPIRED` is the code emitted with that event.
#### Scenario: principal bound at handshake survives subsequent token rotation
- **WHEN** the handshake resolves principal P AND the underlying token rotates server-side
- **THEN** `conn.principal` still reads P AND `connection.principal_expired` fires once the original token's expiry passes
**Verified by:** `src/principal/principal-expiry.test.ts::"principal frozen at handshake; expiry emits connection.principal_expired"`
---
### Requirement: Per-connection backpressure with default 256-message queue
The engine SHALL enforce a per-connection send queue capped at `maxQueuedMessages` (default 256). When the queue would exceed the cap, the connection MUST receive a `BACKPRESSURE_EVICTED` close frame AND `onClose` fires with `code: WS_BUN_BACKPRESSURE_EVICTED`. The eviction MUST be observable via `ws.backpressure_evicted_total` counter.
#### Scenario: queue overflow evicts the connection
- **WHEN** `maxQueuedMessages: 4` AND 5 sends are issued before the client reads any
- **THEN** the connection receives a `BACKPRESSURE_EVICTED` close frame AND `ws.backpressure_evicted_total` increments by 1
**Verified by:** `src/backpressure/backpressure.test.ts::"queue overflow evicts connection with BACKPRESSURE_EVICTED"`
---
### Requirement: Heartbeat with default 30000ms interval and 2-missed-pong drop
The engine SHALL send a ping frame every `heartbeatMs` (default 30000). Two consecutive missed pongs (default `missedPongLimit: 2`) MUST be treated as a dead connection — `onClose` fires with `code: ABNORMAL` AND `ws.heartbeat_failures_total` increments.
#### Scenario: 2 missed pongs drops the connection
- **WHEN** the client never responds to pings AND `missedPongLimit: 2`
- **THEN** after the 2nd missed-pong window, the connection is dropped AND `ws.heartbeat_failures_total` increments by 1
**Verified by:** `src/heartbeat/heartbeat.test.ts::"2 missed pongs drops the connection with code ABNORMAL"`
---
### Requirement: Per-tenant connection cap
The engine SHALL track active connections per tenant and reject new handshakes when a tenant's count is `>= maxConnectionsPerTenant` (default 1000). Rejection returns HTTP 429 AND `WS_BUN_TENANT_CAP_EXCEEDED`.
#### Scenario: 1001st connection from same tenant rejected with 429
- **WHEN** `maxConnectionsPerTenant: 1000` AND tenant T already has 1000 active connections
- **THEN** the 1001st handshake returns 429 AND `WS_BUN_TENANT_CAP_EXCEEDED`
**Verified by:** `src/limits/tenant-cap.test.ts::"1001st connection from same tenant rejected with WS_BUN_TENANT_CAP_EXCEEDED"`
---
### Requirement: Reconnect tokens with HMAC signing and 5-minute TTL
On `onClose` the engine SHALL issue a reconnect token: HMAC-signed (key from `reconnectTokenSecret`), 5-minute TTL (`reconnectTokenTtlMs` default 300000), payload `{ tenant, principalId, lastEventId }`. A client reconnecting with `?reconnectToken=...` MUST be validated; on success the engine resumes subscriptions automatically and the plugin's `onConnect` runs in `resume` mode with the pre-restored subscription set. Invalid token → `WS_BUN_RECONNECT_TOKEN_INVALID`. Expired token → `WS_BUN_RECONNECT_TOKEN_EXPIRED`. Replayed token (already consumed) → `WS_BUN_RECONNECT_TOKEN_INVALID`.
#### Scenario: valid token resumes subscriptions
- **WHEN** the client reconnects with a valid token within 5 minutes
- **THEN** the resumed connection has the same subscription set as before close
#### Scenario: expired token is rejected
- **WHEN** the client reconnects 6 minutes after close
- **THEN** the handshake fails with `WS_BUN_RECONNECT_TOKEN_EXPIRED`
#### Scenario: tampered token is rejected
- **WHEN** the client modifies the token's payload before reconnecting
- **THEN** HMAC validation fails AND the handshake fails with `WS_BUN_RECONNECT_TOKEN_INVALID`
**Verified by:** `src/reconnect/reconnect-token.test.ts::"valid token resumes subscriptions in resume mode"` AND `src/reconnect/reconnect-token.test.ts::"expired token rejected with WS_BUN_RECONNECT_TOKEN_EXPIRED"` AND `src/reconnect/reconnect-token.test.ts::"tampered token rejected with WS_BUN_RECONNECT_TOKEN_INVALID"`
---
### Requirement: Reconnect event-buffer storage default
The engine SHALL provide a per-`(tenant, topic)` event buffer used during reconnect-resume to replay events with id `> lastEventId`. Storage MUST follow `reconnectEventBufferStrategy`:
- `"auto"` (default): when a Redis broker (`api-ws-broker-redis`) is wired into the host, the buffer lives in Redis; otherwise it lives in-memory on the local host. This pins the design doc §11.5 paragraph 4 default ("Redis when the broker is Redis, in-memory otherwise") — explicitly resolving the §14 open question for the foundation's shipping default.
- `"memory"`: always in-memory regardless of broker (single-host deployments).
- `"broker"`: always broker-backed; requires a broker capable of supporting the buffer (currently only `api-ws-broker-redis`); refuses with `WS_BUN_RECONNECT_BUFFER_MISS` at boot if no compatible broker is wired.
#### Scenario: auto resolves to broker when Redis broker is wired
- **WHEN** `reconnectEventBufferStrategy: "auto"` AND `api-ws-broker-redis` is wired
- **THEN** the engine uses the broker-backed buffer
#### Scenario: auto resolves to memory when no broker is wired
- **WHEN** `reconnectEventBufferStrategy: "auto"` AND no broker is wired (single-host)
- **THEN** the engine uses the in-memory buffer
#### Scenario: broker strategy without a compatible broker refuses at boot
- **WHEN** `reconnectEventBufferStrategy: "broker"` AND no compatible broker is wired
- **THEN** boot fails with `WS_BUN_RECONNECT_BUFFER_MISS`
**Verified by:** `src/reconnect/event-buffer.test.ts::"auto resolves to broker when Redis broker wired"` AND `src/reconnect/event-buffer.test.ts::"auto resolves to memory when no broker wired"` AND `src/reconnect/event-buffer.test.ts::"broker strategy without compatible broker refuses at boot with WS_BUN_RECONNECT_BUFFER_MISS"`
---
### Requirement: Subscription bus with cross-tenant capability gate
`conn.subscribe(topic, { tenant?, filter? })` SHALL register the connection against the host's subscription bus, indexed by `(topic, tenant)`. `bus.broadcast(topic, { tenant, payload })` MUST fan out only to matching connections AND run optional per-connection `filter` predicates. Cross-tenant fan-out (a `bus.broadcast` whose `tenant` value differs from the broadcasting plugin's tenant) SHALL require the plugin to declare the `ws.broadcast-cross-tenant` capability; absence → refuse + audit log emitted.
#### Scenario: tenant-scoped broadcast delivered only to matching tenant
- **WHEN** a broadcast for tenant T fires AND connections C1 (tenant T) and C2 (tenant U) are subscribed
- **THEN** C1 receives the payload AND C2 does not
#### Scenario: cross-tenant broadcast without capability is refused
- **WHEN** a plugin without `ws.broadcast-cross-tenant` issues `bus.broadcast(topic, { tenant: "other" })`
- **THEN** the broadcast is refused AND an audit log entry is emitted
**Verified by:** `src/registry/connection-registry.test.ts::"tenant-scoped broadcast delivered only to matching tenant"` AND `src/registry/connection-registry.test.ts::"cross-tenant broadcast without capability refused and audit-logged"`
---
### Requirement: OTel surface
The engine SHALL emit every instrument listed in design §11.7:
- `ws.handshake.duration_ms` histogram `{ plugin, path }`.
- `ws.connections` observable gauge `{ plugin, tenant }`.
- `ws.messages_in_total` counter `{ plugin, path }`.
- `ws.messages_out_total` counter `{ plugin, path }`.
- `ws.broadcasts_total` counter `{ topic, tenant, recipients }`.
- `ws.backpressure_evicted_total` counter `{ plugin }`.
- `ws.heartbeat_failures_total` counter `{ plugin }`.
#### Scenario: handshake emits duration histogram
- **WHEN** a connection completes its handshake
- **THEN** an in-memory `MeterProvider` observes one observation in `ws.handshake.duration_ms`
#### Scenario: broadcast emits broadcasts_total
- **WHEN** a tenant-scoped broadcast is issued with 3 recipients
- **THEN** `ws.broadcasts_total{recipients=3}` increments by 1
**Verified by:** `src/instruments/instruments.test.ts::"handshake emits ws.handshake.duration_ms"` AND `src/instruments/instruments.test.ts::"broadcast emits ws.broadcasts_total with recipient count"`

View File

@@ -0,0 +1,202 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-ws (api-ws)
**Package**: `@suaveplan/api-ws`
**Status**: ACTIVE
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: WsConnection contract
The package SHALL export `WsConnection` with: `id` (ULID), `principal: Principal`, `tenantId: TenantId`, `lastEventId: string | null`, `state: "open" | "closing" | "closed"`, methods `subscribe(topic, opts?)`, `unsubscribe(topic)`, `send(message)`, `close(code, reason?)`, `ack(id)`. The principal MUST be frozen at handshake for the connection's lifetime; token expiry mid-connection fires `connection.principal_expired` (the host emits this; the contract surfaces it).
#### Scenario: every method present
- **WHEN** `expectTypeOf<WsConnection>()` is checked
- **THEN** every documented method is declared with the documented signature
**Verified by:** `src/connection/connection.spec-conformance.test.ts::"WsConnection declares every documented method"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: ConnectionRegistry multi-index
The package SHALL export `ConnectionRegistry` with O(1) lookup by `tenantId` and by `(topic, tenantId)`. `add`, `remove`, `lookupByTenant(tenant)`, `lookupByTopic(topic, tenant?)`, `countByTenant(tenant)`. `remove` MUST be idempotent (removing an absent connection is a no-op).
#### Scenario: lookup by tenant returns matching set
- **WHEN** 3 connections are added for tenant T1 and 2 for tenant T2
- **THEN** `lookupByTenant("T1").length === 3` AND `lookupByTenant("T2").length === 2`
#### Scenario: remove is idempotent
- **WHEN** `remove(conn)` is called twice
- **THEN** the second call returns without throwing
**Verified by:** `src/connection-registry/connection-registry.test.ts::"lookupByTenant returns matching set"` AND `src/connection-registry/connection-registry.test.ts::"remove is idempotent"`
---
### Requirement: SubscriptionBus broadcast with filter
The package SHALL export `SubscriptionBus.broadcast(topic, { tenant, payload, filter? })` which delivers the payload to every connection registered against `(topic, tenant)` whose optional `filter(payload)` returns true. The payload MUST be encoded once and sent to each matching connection (no per-connection re-encoding).
#### Scenario: broadcast reaches matching connections
- **WHEN** two connections subscribe to `orders.created` for tenant T
- **THEN** `broadcast("orders.created", { tenant: T, payload })` reaches both
#### Scenario: filter excludes non-matching connections
- **WHEN** filter returns `false` for one of two matching connections
- **THEN** only the connection where filter returns true receives the payload
**Verified by:** `src/subscription-bus/subscription-bus.test.ts::"broadcast reaches all matching connections"` AND `src/subscription-bus/subscription-bus.test.ts::"filter predicate excludes non-matching connections"`
---
### Requirement: Cross-tenant broadcast requires explicit capability
`SubscriptionBus.broadcast(topic, { tenant, payload })` where `tenant` differs from the caller's `principal.tenantId` (or `tenant` is the wildcard "*") MUST throw `WS.CROSS_TENANT_BROADCAST_DENIED` unless the caller's `Caps` includes `"ws.broadcast-cross-tenant"`. Default is deny.
#### Scenario: same-tenant broadcast succeeds without capability
- **WHEN** caller's tenant is T AND broadcast is for tenant T
- **THEN** broadcast succeeds
#### Scenario: cross-tenant denied by default
- **WHEN** caller's tenant is T AND broadcast is for tenant T2 AND `Caps` does NOT include `ws.broadcast-cross-tenant`
- **THEN** broadcast throws `WS.CROSS_TENANT_BROADCAST_DENIED`
**Verified by:** `src/subscription-bus/cross-tenant.test.ts::"same-tenant broadcast succeeds without capability"` AND `src/subscription-bus/cross-tenant.test.ts::"cross-tenant denied without explicit capability"`
---
### Requirement: WsBroker contract
The package SHALL export `WsBroker` for multi-host fan-out: `publish(topic, envelope)`, `subscribe(topic, handler)`, `dispose()`. The broker MUST NOT migrate connections — it only makes broadcasts visible across host replicas. `dispose()` MUST close every subscription cleanly.
#### Scenario: every method present
- **WHEN** `expectTypeOf<WsBroker>()` is checked
- **THEN** all three methods are declared
**Verified by:** `src/broker/broker.spec-conformance.test.ts::"WsBroker declares publish subscribe dispose"`
---
### Requirement: Envelope Zod schema
The package SHALL export `wsEnvelopeSchema: z.ZodSchema<WsEnvelope>` describing the wire envelope: `{ id: string, type: string, topic?: string, payload?: unknown, ts?: string }`. Unknown top-level keys MUST be rejected.
#### Scenario: minimal envelope parses
- **WHEN** `{ id: "x", type: "msg" }` is parsed
- **THEN** it succeeds
#### Scenario: extra key rejected
- **WHEN** `{ id: "x", type: "msg", extra: 1 }` is parsed
- **THEN** Zod reports an issue at `extra`
**Verified by:** `src/envelope/envelope.test.ts::"minimal envelope parses"` AND `src/envelope/envelope.test.ts::"unknown keys rejected"`
---
### Requirement: Backpressure policy
The package SHALL export `BackpressurePolicy` with default `maxQueuedMessages = 256`. When a connection's send queue exceeds the cap, the connection MUST receive a `BACKPRESSURE_EVICTED` close frame and be dropped from the registry.
#### Scenario: queue saturation triggers eviction
- **WHEN** 257 messages are queued for a connection with default policy
- **THEN** the eviction handler fires AND `WS.BACKPRESSURE_EVICTED` is the close code
**Verified by:** `src/backpressure/backpressure.test.ts::"queue saturation triggers BACKPRESSURE_EVICTED"`
---
### Requirement: Heartbeat policy
The package SHALL export `HeartbeatPolicy` with default `heartbeatMs = 30000`. Two missed pongs MUST trigger `onClose` with code `ABNORMAL`.
#### Scenario: two missed pongs trigger close
- **WHEN** a fake clock advances past 2 × heartbeatMs without pong
- **THEN** the close handler fires with `code === "ABNORMAL"` AND `WS.HEARTBEAT_FAILED` is emitted
**Verified by:** `src/heartbeat/heartbeat.test.ts::"two missed pongs trigger ABNORMAL close"`
---
### Requirement: Reconnect-token shape
The package SHALL export `ReconnectToken` HMAC envelope carrying `{ tenant, principalId, lastEventId, exp }` with default 5-minute TTL. The HMAC verifier MUST reject expired tokens (`WS.RECONNECT_TOKEN_INVALID`) and tokens with bad MAC.
#### Scenario: valid token verifies
- **WHEN** a token signed within TTL is verified
- **THEN** the verifier returns the parsed payload
#### Scenario: expired token rejected
- **WHEN** the fake clock advances past TTL
- **THEN** the verifier throws `WS.RECONNECT_TOKEN_INVALID`
**Verified by:** `src/reconnect-token/reconnect-token.test.ts::"valid token verifies"` AND `src/reconnect-token/reconnect-token.test.ts::"expired token rejected"`
---
### Requirement: CapabilityId augmentation contributes "ws" and "ws.broadcast-cross-tenant"
The package SHALL augment the `CapabilityId` union to include both ids.
#### Scenario: both ids assignable to CapabilityId
- **WHEN** `expectTypeOf<"ws">().toMatchTypeOf<CapabilityId>()` AND `expectTypeOf<"ws.broadcast-cross-tenant">().toMatchTypeOf<CapabilityId>()` are checked
- **THEN** both pass
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"ws and ws.broadcast-cross-tenant are valid CapabilityIds"`
---
### Requirement: Error codes registry frozen
The package SHALL export `WS_CODES` containing exactly: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BACKPRESSURE_EVICTED`, `HEARTBEAT_FAILED`, `CONNECTION_CAP_EXCEEDED`, `CROSS_TENANT_BROADCAST_DENIED`, `RECONNECT_TOKEN_INVALID`, `BROKER_UNAVAILABLE`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `WS_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every WS_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for engines
The package SHALL export `runWsEngineConformance({ factory, label })`. Every concrete engine + broker pair MUST pass.
#### Scenario: harness covers handshake, subscribe, broadcast, backpressure, heartbeat, reconnect
- **WHEN** the harness is run against a stub engine
- **THEN** test categories exist for every documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers handshake subscribe broadcast backpressure heartbeat reconnect"`

267
openspec/specs/api/spec.md Normal file
View File

@@ -0,0 +1,267 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api
**Package**: `@suaveplan/api`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: SuaveApp composes pre-built dependencies, not creates them
`SuaveApp` constructor SHALL accept `{ pluginRegistry: PluginRegistry; openAPIRegistry?: OpenAPIRegistry; middlewareChain?: MiddlewareChain; tenantResolver?: TenantResolver; migrationRunner?: MigrationRunner; config: AppConfig; logger?: LoggerLike }`. It SHALL NOT internally construct any of those — callers (or `AppBuilder`) wire them. Dependency inversion eliminates upstream's deep import coupling.
#### Scenario: SuaveApp does not instantiate PluginRegistry
- **WHEN** `new SuaveApp({ pluginRegistry: stubRegistry, config })` is constructed
- **THEN** `app.registry === stubRegistry` (no internal reconstruction)
**Verified by:** `src/app/app.test.ts::"composes injected pluginRegistry without reconstruction"` AND `src/app/app.test.ts::"missing pluginRegistry raises API_INVALID_CONFIG"`
---
### Requirement: AppBuilder.tenant(resolver) actually wires into middleware
When `AppBuilder.tenant(resolver).build()` is called, the produced `SuaveApp` SHALL register `createTenantMiddleware({ resolver, ... })` from `@suaveplan/api-tenant` into the middleware chain at `MiddlewareOrder.TENANT`. This is a deliberate fix for the upstream bug where the resolver is plumbed but silently dropped.
#### Scenario: tenant resolver reaches the middleware chain
- **WHEN** `defineApp().tenant(stubResolver).build()` is invoked and the middleware chain is inspected
- **THEN** an entry with `meta.name === "tenant"` exists and its resolver === stubResolver
**Verified by:** `src/builder/builder.test.ts::"tenant(resolver) wires createTenantMiddleware"` AND `src/builder/builder.test.ts::"no tenant() call leaves chain without tenant middleware"`
---
### Requirement: AppState FSM transitions are monotonic
`app.state` SHALL move through `created → initializing → ready → running → stopping → stopped`. Calling `initialize()` from any state other than `created` SHALL raise `ApiError(API_INVALID_STATE)`. `start()` from `created` SHALL auto-initialize first. `stop()` from non-`running` SHALL be a no-op (idempotent).
#### Scenario: double-initialize raises API_INVALID_STATE
- **WHEN** `app.initialize()` is awaited, then `app.initialize()` is awaited again
- **THEN** the second call rejects with `ApiError(API_INVALID_STATE)`
#### Scenario: stop is idempotent from stopped
- **WHEN** `app.stop()` is called twice in succession from `running`
- **THEN** the second call resolves without error and leaves state at `stopped`
**Verified by:** `src/app/app.test.ts::"double initialize raises API_INVALID_STATE"` AND `src/app/app.test.ts::"stop is idempotent from stopped"` AND `src/app/app.test.ts::"start from created auto-initializes"`
---
### Requirement: Config loader resolves file → env-file → env-vars
`loadConfig(options)` SHALL merge in the order: defaults → file (`.ts` → `.js` → `.json` extensions tried in that order) → environment-specific file (`{name}.{env}.json`) → environment variables (`SUAVEPLAN_<KEY_PATH>` parsed into nested object via `_` → `.`). Later sources override earlier. Yaml and toml file formats SHALL NOT be supported (upstream advertised them but threw at runtime — genesis drops the union entries).
#### Scenario: env var overrides file
- **WHEN** `suaveplan.config.json` contains `server.port: 3000` and `SUAVEPLAN_SERVER_PORT=4000` is set, then `loadConfig()` is awaited
- **THEN** the result has `server.port: 4000`
#### Scenario: yaml extension not tried
- **WHEN** `loadConfig({ configName: "suaveplan.config" })` is called and only `suaveplan.config.yaml` exists
- **THEN** the result is the defaults-only config (yaml is not loaded)
**Verified by:** `src/config/loader.test.ts::"env var overrides file"` AND `src/config/loader.test.ts::"env-specific file overrides base file"` AND `src/config/loader.test.ts::"yaml extension is not attempted"`
---
### Requirement: validateConfig raises typed API_INVALID_CONFIG
`validateConfig(input)` SHALL parse against `AppConfigSchema` and throw `ApiError(API_INVALID_CONFIG)` with the ZodError as `cause` on failure. `tryValidateConfig(input)` SHALL return `{success: false, errors: ConfigValidationErrorDetail[]}` on failure (no throw). `validatePluginConfig<T>(schema, input, pluginName)` SHALL throw `ApiError(API_INVALID_CONFIG)` with `context.pluginName` on failure.
#### Scenario: validateConfig with missing required field throws
- **WHEN** `validateConfig({})` is called (missing required `name` + `version` + `openapi`)
- **THEN** an `ApiError(API_INVALID_CONFIG)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/config/validator.test.ts::"validateConfig throws on missing fields"` AND `src/config/validator.test.ts::"tryValidateConfig returns errors on missing fields"` AND `src/config/validator.test.ts::"validatePluginConfig carries pluginName context"`
---
### Requirement: MiddlewareChain insertBefore/insertAfter raise on missing target
`chain.insertBefore(name, m)` and `chain.insertAfter(name, m)` SHALL raise `ApiError(API_MIDDLEWARE_INSERT_TARGET_NOT_FOUND)` when no middleware with the named target exists. Upstream silently falls back to `push` — genesis fails closed so callers learn about the mismatch immediately.
#### Scenario: insertBefore on missing name raises
- **WHEN** `chain.insertBefore("auth", myMiddleware)` is called and no "auth" middleware exists
- **THEN** `ApiError(API_MIDDLEWARE_INSERT_TARGET_NOT_FOUND)` is thrown
#### Scenario: insertBefore on existing name splices
- **WHEN** "cors" exists at index 1 and `chain.insertBefore("cors", myMiddleware)` is called
- **THEN** `myMiddleware` is at index 1, "cors" is at index 2
**Verified by:** `src/middleware/chain.test.ts::"insertBefore raises on missing target"` AND `src/middleware/chain.test.ts::"insertBefore splices on existing target"` AND `src/middleware/chain.test.ts::"insertAfter raises on missing target"`
---
### Requirement: Error handler passes full SuaveplanError statusCode through
`createErrorHandler()` SHALL respond with `error.statusCode` as the HTTP status — full range, not a closed enum. SuaveplanError subclasses with statusCode 409, 422, 503, 504, etc. SHALL respond with those codes. ZodError → 400 with `ValidationError.fromZodError(err).toJSON()`. HTTPException → its status. Unknown → 500 with `INTERNAL_ERROR`.
#### Scenario: 422 SuaveplanError responds 422
- **WHEN** a route throws a `SuaveplanError` with `statusCode: 422` and the error handler runs
- **THEN** the response status is 422
#### Scenario: ZodError responds 400
- **WHEN** a route throws a `ZodError` and the error handler runs
- **THEN** the response status is 400 with `error.code === "VALIDATION_ERROR"` in the body
**Verified by:** `src/middleware/error-handler.test.ts::"422 SuaveplanError responds 422"` AND `src/middleware/error-handler.test.ts::"ZodError responds 400 VALIDATION_ERROR"` AND `src/middleware/error-handler.test.ts::"unknown error responds 500 INTERNAL_ERROR"`
---
### Requirement: CORS conditionally exposes X-RateLimit-* headers
`createCors(options)` SHALL only include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` in the default `exposeHeaders` when `options.withRateLimitHeaders === true`. Default omits them.
#### Scenario: default omits rate-limit headers
- **WHEN** `createCors({})` is built and its exposeHeaders is inspected
- **THEN** none of `X-RateLimit-Limit/Remaining/Reset` is present
#### Scenario: withRateLimitHeaders adds them
- **WHEN** `createCors({ withRateLimitHeaders: true })` is built
- **THEN** all three rate-limit headers are present
**Verified by:** `src/middleware/cors.test.ts::"default omits rate-limit headers"` AND `src/middleware/cors.test.ts::"withRateLimitHeaders adds them"`
---
### Requirement: Health routes are configurable
`SuaveApp` SHALL accept `healthRoutes?: { healthPath?: string; readyPath?: string; livePath?: string; enabled?: boolean }`. Default matches upstream (`/health` + `/ready` enabled, no `/live`). `enabled: false` SHALL skip registration entirely.
#### Scenario: custom health path
- **WHEN** `new SuaveApp({ ..., healthRoutes: { healthPath: "/healthz" } })` is built and `/healthz` is GET'd
- **THEN** the response is the standard health payload
#### Scenario: disabled skips registration
- **WHEN** `new SuaveApp({ ..., healthRoutes: { enabled: false } })` is built and `/health` is GET'd
- **THEN** the response is 404 (route not registered)
**Verified by:** `src/app/health-routes.test.ts::"custom healthPath responds"` AND `src/app/health-routes.test.ts::"enabled false skips registration"`
---
### Requirement: No global singletons exported
The package SHALL NOT export `globalApp`, `getGlobalApp`, `setGlobalApp`, `clearGlobalApp`, `globalEventBus`, or any other module-level mutable state. Each `SuaveApp` instance is independent; multi-app deployments compose freely.
#### Scenario: no global accessor in barrel
- **WHEN** the package barrel is inspected for `setGlobalApp` and `getGlobalApp`
- **THEN** neither symbol is exported
**Verified by:** `src/app/app.spec-conformance.test.ts::"no global singleton exports"`
---
### Requirement: Optional single RequestContext AsyncLocalStorage
When `app.config.useAsyncLocalStorage === true`, the orchestrator SHALL wrap every request handler in `requestStorage.run({ app, requestId, tenant?, user? }, next)`. `getCurrentRequestContext()` SHALL return that bag from any depth. Default is `false` — no ALS scope created. Replaces upstream's 3 separate ALS instances with 1.
#### Scenario: ALS off → getCurrentRequestContext undefined
- **WHEN** `useAsyncLocalStorage: false` is set and a request runs
- **THEN** `getCurrentRequestContext()` called from inside the handler returns `undefined`
#### Scenario: ALS on → context threads through async stack
- **WHEN** `useAsyncLocalStorage: true` and a request runs
- **THEN** `getCurrentRequestContext()` returns `{ app, requestId, tenant?, user? }` matching what was set up by the middleware
**Verified by:** `src/app/request-context.test.ts::"ALS off keeps getCurrentRequestContext undefined"` AND `src/app/request-context.test.ts::"ALS on threads context through async stack"`
---
### Requirement: Every API_* code is thrown from a production path
`API_CODES` SHALL define exactly 5 codes (`API_INVALID_CONFIG`, `API_INVALID_STATE`, `API_MIDDLEWARE_INSERT_TARGET_NOT_FOUND`, `API_SHUTDOWN_TIMEOUT`, `API_SERVER_START_FAILED`). Every code SHALL be raised by at least one production call site.
**Verified by:** `src/api.spec-conformance.test.ts::"every API_* code is thrown from a production path"`
---
### Requirement: Server-tier package shape with integration tests against all 4 siblings
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration tests SHALL exercise: (a) `SuaveApp` boot with a real `PluginRegistry` containing 3 plugins with deps, (b) tenant middleware wiring through `AppBuilder.tenant`, (c) OpenAPI registry doc emission, (d) migration runner invocation before plugin init.
#### Scenario: full boot integration test
- **WHEN** `tests/integration/full-boot.test.ts` builds an app with all 4 siblings wired (real registry / real openapi / real tenant / real migrations against an in-memory sqlite) and `.start()` is awaited
- **THEN** the app reaches state `running` with all plugins ready and the spec doc is served
**Verified by:** `tests/integration/full-boot.test.ts::"boots with all 4 siblings wired"` AND `tests/dep-graph.test.ts::"dependencies match the allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
---
### Requirement: SuaveApp seeds canonical host services before plugin init
`SuaveApp.initialize()` SHALL call `this._registry.seedServices(...)` immediately before `this._registry.initialize()`. The seeded map SHALL include `app` (the OpenAPIHono instance), `openAPIRegistry`, `logger`, `eventBus`, `clock` always; AND optionally `migrationRunner` (when supplied via `SuaveAppOptions.migrations`) AND `db` (when supplied via the new `SuaveAppOptions.db`). After init, every plugin's `ctx.getService("app")` / `ctx.getService("openAPIRegistry")` / etc. SHALL resolve to the corresponding instance with typed inference via the ServiceRegistry module augmentation.
#### Scenario: plugin resolves seeded app via ctx.getService
- **WHEN** a SuaveApp is built with a plugin whose `onInit(ctx) { recordApp(ctx.getService("app")) }` runs, and `app.initialize()` is awaited
- **THEN** `recordApp` was called with the OpenAPIHono instance the app holds at `this._hono` (referential equality, not a wrapper)
#### Scenario: missing migrationRunner option leaves the slot undefined
- **WHEN** `new SuaveApp({ ...options /* no migrationRunner */ })` is built and a plugin asks `ctx.getService("migrationRunner")`
- **THEN** the result is `undefined`
**Verified by:** `src/app/seed-services.test.ts::"plugin resolves seeded app via ctx.getService"` AND `src/app/seed-services.test.ts::"openAPIRegistry / logger / eventBus / clock are seeded"` AND `src/app/seed-services.test.ts::"missing migrationRunner leaves slot undefined"`
---
### Requirement: ServiceRegistry module augmentation declares canonical host slots
The package SHALL ship a side-effect-importable module at `./services/registry-augmentation` that declares:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
migrationRunner?: MigrationRunner;
db?: SurrealAdapter;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}
```
Consumers SHALL get full type inference on `ctx.getService("app")` (returns `OpenAPIHono | undefined`), etc. The augmentation SHALL be auto-loaded by the package root barrel (`src/index.ts` side-effect imports it) so any consumer of `@suaveplan/api` picks it up without an explicit import. Subpath consumers of `@suaveplan/api/services/registry-augmentation` get it directly.
#### Scenario: ctx.getService("app") type-narrows to OpenAPIHono | undefined
- **WHEN** `expectTypeOf(ctx.getService("app")).toEqualTypeOf<OpenAPIHono | undefined>()` is asserted in the conformance test
- **THEN** the type check passes
**Verified by:** `src/services/registry-augmentation.spec-conformance.test.ts::"every host slot type-narrows correctly via expectTypeOf"` AND `src/services/registry-augmentation.test.ts::"side-effect import auto-loads via root barrel"`
---
### Requirement: SuaveAppOptions.db is optional
`SuaveAppOptions` SHALL accept an optional `db?: SurrealAdapter` field. When supplied, it's seeded into `ServiceRegistry["db"]`; when omitted, the slot stays `undefined`. The constructor SHALL store it on `this._db` for later seeding. No change to existing options.
#### Scenario: db option seeded when provided
- **WHEN** `new SuaveApp({ ...config, db: surrealAdapter })` is built and a plugin queries `ctx.getService("db")`
- **THEN** the result is `surrealAdapter` (referential)
**Verified by:** `src/app/options.test.ts::"db option seeded when provided"` AND `src/app/options.test.ts::"omitting db leaves the slot undefined"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,183 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audience-rules (enterprise-audience-rules)
**Package**: `@suaveplan/audience-rules`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The package SHALL run unchanged in Bun, Node, and browser environments. The `src/` tree SHALL NOT import from `node:*`, access `window` / `document` / `navigator`, or depend on any browser-only or Node-only package.
#### Scenario: tier-purity grep is empty
- **WHEN** the spec-conformance test walks `src/` for `from "node:`, `globalThis.document`, `navigator.userAgent`
- **THEN** zero offenders are reported
**Verified by:** `src/audience-rules.spec-conformance.test.ts::"src tree contains no browser or Node-only globals"`
---
### Requirement: Stable error-code registry
The package SHALL expose `AUDIENCE_RULES_CODES` built via `defineCodes("AUDIENCE_RULES", [...])` and an `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` with optional `segmentId` / `tenantId` discriminants. Every failure path in the package SHALL raise an `AudienceRulesError`, never a bare `Error`.
#### Scenario: every code is constructible
- **WHEN** the test instantiates `new AudienceRulesError(code, "msg", { segmentId, tenantId })` for every member of `AUDIENCE_RULES_CODES`
- **THEN** the resulting error narrows `code` to the namespaced string and exposes the discriminants
**Verified by:** `src/error-codes/error-codes.test.ts::"every registered code constructs an AudienceRulesError with namespaced code"` AND `src/error-codes/error-codes.test.ts::"every code exposes its discriminants on the instance"`
---
### Requirement: Fourteen comparison operators
`SegmentEvaluator.evaluateRule` SHALL support fourteen operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`, `ends_with`. The `contains` / `not_contains` operators SHALL match both array membership and string substring. The `in` / `not_in` operators SHALL check membership in an array of values supplied as the rule value. An operator outside this set SHALL cause `evaluateRule` to throw `AudienceRulesError` with code `UNKNOWN_OPERATOR`.
#### Scenario: each operator returns the documented boolean
- **WHEN** the test runs every operator against a known contact / rule pair
- **THEN** the result matches the expected boolean
#### Scenario: unknown operator raises UNKNOWN_OPERATOR
- **WHEN** `evaluateRule` is called with `operator: "unknown" as never`
- **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.UNKNOWN_OPERATOR` is thrown
**Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"applies every operator with the documented boolean result"` AND `src/segment-evaluator/segment-evaluator.test.ts::"raises UNKNOWN_OPERATOR for an unrecognised operator"`
---
### Requirement: AND/OR logical chaining
When a `Segment` carries multiple rules, the evaluator SHALL combine them left-to-right respecting each rule's `logicalOp` (default `"and"`). The first rule's `logicalOp` is ignored; each subsequent rule combines with the running result. An empty rule list SHALL match every contact.
#### Scenario: AND narrows the match set
- **WHEN** a segment carries two rules combined with `logicalOp: "and"`
- **THEN** only contacts matching both rules appear in the result
#### Scenario: OR widens the match set
- **WHEN** a segment carries two rules where the second has `logicalOp: "or"`
- **THEN** contacts matching either rule appear in the result
#### Scenario: empty rules match all
- **WHEN** a segment carries zero rules
- **THEN** the evaluator returns every contact
**Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"narrows the match set when rules are AND-combined"` AND `src/segment-evaluator/segment-evaluator.test.ts::"widens the match set when rules are OR-combined"` AND `src/segment-evaluator/segment-evaluator.test.ts::"matches every contact when the rule list is empty"`
---
### Requirement: Dot-notation field resolution
The evaluator SHALL resolve dot-separated paths against the contact, supporting numeric indices for array elements (e.g. `channels.0.type`). Resolution against a missing intermediate path SHALL return `undefined` (the rule does not match unless the operator is `not_exists`).
#### Scenario: nested object path resolves
- **WHEN** the rule field is `metadata.country` and the contact has `{ metadata: { country: "US" } }`
- **THEN** the evaluator resolves to `"US"`
#### Scenario: array index resolves
- **WHEN** the rule field is `channels.0.type` and the contact has a single channel of type `"email"`
- **THEN** the evaluator resolves to `"email"`
#### Scenario: missing nested path returns undefined
- **WHEN** the rule field is `a.b.c.d` and the contact does not carry `a`
- **THEN** the evaluator treats the value as `undefined`
**Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"resolves dot-notation paths through nested objects"` AND `src/segment-evaluator/segment-evaluator.test.ts::"resolves numeric indices into array fields"` AND `src/segment-evaluator/segment-evaluator.test.ts::"returns undefined for missing nested paths"`
---
### Requirement: Predicate factories
The package SHALL export seven `SegmentRule` factories: `hasTag(tag)`, `channelIs(type)`, `statusIs(status)`, `lastActivityBefore(date)`, `lastActivityAfter(date)`, `metadataEquals(field, value)`, `metadataContains(field, value)`. Each factory SHALL return a `SegmentRule` whose `field` / `operator` / `value` produce a match consistent with the predicate's name.
#### Scenario: each predicate produces the documented rule shape
- **WHEN** each factory is called with a representative input
- **THEN** the returned `SegmentRule` carries the documented `field` and `operator`
**Verified by:** `src/predicates/predicates.test.ts::"hasTag produces a tags-contains rule"` AND `src/predicates/predicates.test.ts::"channelIs produces a channels.0.type-eq rule"` AND `src/predicates/predicates.test.ts::"statusIs produces a status-eq rule"` AND `src/predicates/predicates.test.ts::"lastActivityBefore produces a lastActivityAt-lt rule"` AND `src/predicates/predicates.test.ts::"lastActivityAfter produces a lastActivityAt-gt rule"` AND `src/predicates/predicates.test.ts::"metadataEquals produces a metadata-field-eq rule"` AND `src/predicates/predicates.test.ts::"metadataContains produces a metadata-field-contains rule"`
---
### Requirement: SegmentStore with Clock and IdGenerator injection
The package SHALL expose a `SegmentStore` interface with `create`, `getById`, `update`, `delete`, `list`, `updateCache` methods and an `InMemorySegmentStore` implementation. `InMemorySegmentStore` SHALL accept optional `clock: Clock` and `idGenerator: IdGenerator` injections from `@suaveplan/types` (defaults `() => Date.now()` and a deterministic per-instance counter). Production paths SHALL NOT call `Date.now()`, `new Date()` directly, or mutate a module-scope counter.
#### Scenario: injected Clock controls timestamps
- **WHEN** an `InMemorySegmentStore` is constructed with `clock: () => 1_700_000_000_000`
- **THEN** every `createdAt` / `updatedAt` / `lastRefreshedAt` reports `new Date(1_700_000_000_000)`
#### Scenario: injected IdGenerator controls segment IDs
- **WHEN** an `InMemorySegmentStore` is constructed with `idGenerator: () => "seg_fixed"`
- **THEN** `store.create({...}).id === "seg_fixed"`
**Verified by:** `src/store/store.test.ts::"uses injected Clock for createdAt and updatedAt"` AND `src/store/store.test.ts::"uses injected IdGenerator for segment IDs"`
---
### Requirement: Multi-tenant isolation
`SegmentStore.list(tenantId)` SHALL return only segments whose `tenantId` matches the argument. Cross-tenant segments SHALL be invisible to `list` even when stored in the same instance.
#### Scenario: list scopes to tenant
- **WHEN** an `InMemorySegmentStore` holds segments for `"tenant-a"` and `"tenant-b"`
- **THEN** `store.list("tenant-a")` returns only `tenant-a`'s segments
**Verified by:** `src/store/store.test.ts::"list returns only segments for the requested tenant"`
---
### Requirement: SegmentRefresher event emission
`SegmentRefresher` SHALL expose `refresh(segmentId)` and `refreshAll(tenantId)` methods. Each successful refresh SHALL update the store cache via `updateCache` and emit `segment:refreshed` on its `events: EventBus<SegmentRefresherEventMap>` field. `refresh` SHALL return `undefined` for an unknown segment ID without emitting. `refreshAll` SHALL emit once per refreshed segment.
#### Scenario: refresh updates cache and emits
- **WHEN** `refresher.refresh(segment.id)` is called for an existing segment
- **THEN** the segment's `cachedContactIds` / `cachedCount` / `lastRefreshedAt` are updated and a `segment:refreshed` listener observes `{ segmentId, result }`
#### Scenario: unknown segment returns undefined
- **WHEN** `refresher.refresh("missing")` is called
- **THEN** the result is `undefined` and no event is emitted
#### Scenario: refreshAll emits once per segment
- **WHEN** a tenant carries three segments and `refresher.refreshAll(tenant)` is called
- **THEN** the `segment:refreshed` listener is invoked three times
**Verified by:** `src/segment-refresher/segment-refresher.test.ts::"refresh updates the store cache and emits segment:refreshed"` AND `src/segment-refresher/segment-refresher.test.ts::"refresh returns undefined for an unknown segment ID"` AND `src/segment-refresher/segment-refresher.test.ts::"refreshAll emits segment:refreshed once per refreshed segment"`
---
### Requirement: Zod-validated input boundaries
Every public `SegmentStore.create` / `SegmentStore.update` call SHALL validate its input via a Zod schema before persisting. Validation failure SHALL raise `AudienceRulesError` with code `INVALID_INPUT` (never a raw `ZodError`).
#### Scenario: invalid create input is rejected
- **WHEN** `InMemorySegmentStore.create({ name: "", tenantId: "t1", rules: [], refreshMode: "manual" })` is called with an empty name
- **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` is thrown and no segment is persisted
#### Scenario: invalid update input is rejected
- **WHEN** `InMemorySegmentStore.update(id, { name: "" })` is called with an empty name
- **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` is thrown
**Verified by:** `src/store/store.test.ts::"rejects create with empty name and raises INVALID_INPUT"` AND `src/store/store.test.ts::"rejects update with empty name and raises INVALID_INPUT"`

View File

@@ -0,0 +1,196 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-analysis-beat
**Package**: `@suaveplan/audio-analysis-beat`
**Status**: SHIPPED (2026-05-23)
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-analysis-beat` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All FFT, onset-detection-function, and tempo-estimation work SHALL be performed with hand-rolled numeric code over `Float32Array` / `Float64Array` so the package runs identically in Bun, Node.js, Deno, and browsers. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
---
### Requirement: BeatDetector implements the AnalysisOp contract
The package SHALL export a `BeatDetector` class implementing the `AnalysisOp` interface defined by the `@suaveplan/audio-core` ecosystem (plugin slot 3 — Analysis Op). It SHALL expose a readonly `name` of `"beat-detector"` and a readonly `description` of `"Detect tempo and beat locations"`, and SHALL be constructible with an optional `BeatDetectorOptions` argument. As an Analysis Op it SHALL run as a sidecar scan — it SHALL NOT register itself in any `OpRegistry` plan or process slot and SHALL NOT mutate the `AudioDocument` it analyses.
#### Scenario: Detector exposes the AnalysisOp surface
- **WHEN** a caller constructs `new BeatDetector()`
- **THEN** `detector.name === "beat-detector"`, `detector.description` is the documented string, and `analyze`, `analyzeStream`, and `analyzeBlock` are all callable
#### Scenario: Analysis does not mutate the source document
- **WHEN** a caller runs `analyze(doc)` on a document
- **THEN** `doc.edits`, `doc.version`, and `doc.pages` are unchanged after the call returns
**Verified by:** `src/detector/beat-detector.test.ts::"exposes the AnalysisOp name, description, and methods"` AND `src/detector/beat-detector.test.ts::"analyze leaves the source document unmutated"`
---
### Requirement: Full-document tempo and beat analysis
`BeatDetector.analyze(doc, opts?)` SHALL return a `Promise<BeatResult>` where `BeatResult` extends `AnalysisResult` and carries `bpm` (a positive number within the configured `minBpm`/`maxBpm` range), `beats` (a `Float64Array` of beat timestamps in seconds, strictly monotonically increasing), `onsets` (a `Float64Array` of onset timestamps in seconds, strictly monotonically increasing), and `confidence` (a number in the closed interval `[0, 1]`). The carried `op` field SHALL equal `"beat-detector"`, `timestamp` SHALL be `0`, and `duration` SHALL equal the analysed document duration in seconds. A synthetic constant-tempo signal SHALL yield a `bpm` equal to its true tempo within ±1 BPM.
#### Scenario: Constant-tempo metronome yields the exact BPM
- **WHEN** `analyze` runs over a synthetic 120 BPM metronome click track
- **THEN** `result.bpm` is within ±1 of `120`, `result.beats` is strictly increasing, and `result.confidence` is in `[0, 1]`
#### Scenario: Result fields are well-formed
- **WHEN** `analyze` returns a `BeatResult`
- **THEN** `result.op === "beat-detector"`, `result.beats` and `result.onsets` are each strictly monotonically increasing, and `result.duration` equals the document duration in seconds
**Verified by:** `src/detector/beat-detector.test.ts::"analyze reports the true BPM for a synthetic metronome"` AND `tests/property.test.ts::"beats and onsets are strictly increasing and confidence is in [0,1]"`
---
### Requirement: Three selectable detection algorithms
The package SHALL ship three onset/tempo algorithms selectable via `BeatDetectorOptions.algorithm`: `"energy"` (default) — positive first-differences of per-block energy stats sourced from `@suaveplan/audio-core` block statistics; `"spectral-flux"` — onset detection from frame-to-frame magnitude-spectrum increases via a hand-rolled real FFT; and `"comb"` — comb-filter / autocorrelation tempo estimation over an onset-detection function. Each algorithm SHALL be exported as an independently importable module under `./algorithms/*` and SHALL produce a `bpm` within the configured range. A `beat-grid` helper SHALL phase-align detected onsets to a regular grid once the tempo is known.
#### Scenario: Each algorithm produces an in-range BPM
- **WHEN** `analyze` runs over the same metronome track once per `algorithm` value (`"energy"`, `"spectral-flux"`, `"comb"`)
- **THEN** every run returns a `bpm` within `[minBpm, maxBpm]` and within ±2 of the true tempo
#### Scenario: Beat grid phase-aligns onsets to a regular grid
- **WHEN** the `beat-grid` helper is given an onset list and a known BPM
- **THEN** it returns a beat grid whose inter-beat interval equals `60 / bpm` seconds and whose phase minimises total onset-to-grid distance
**Verified by:** `src/algorithms/spectral-flux.test.ts::"spectral-flux ODF detects onsets on a metronome track"` AND `src/algorithms/comb-tempo.test.ts::"comb-filter estimation returns the true tempo"` AND `src/algorithms/beat-grid.test.ts::"beat grid phase-aligns onsets to a 60/bpm interval"`
---
### Requirement: Streaming refinement analysis
`BeatDetector.analyzeStream(doc, opts?)` SHALL be an async generator yielding `BeatResult` values that refine as more of the document is processed. Each yielded result SHALL be well-formed (BPM in range, monotonic `beats`/`onsets`, `confidence` in `[0, 1]`), and the `confidence` of later yields SHALL be greater than or equal to that of earlier yields for a stable-tempo signal. The final yielded result SHALL be deep-equal in `bpm` to what `analyze` returns for the same document and options.
#### Scenario: Streaming yields refining results
- **WHEN** a caller fully drains `analyzeStream` over a stable-tempo document
- **THEN** every yielded `BeatResult` is well-formed and the final yield's `bpm` matches `analyze`'s `bpm` for the same input
**Verified by:** `src/detector/beat-detector.test.ts::"analyzeStream yields refining results converging on analyze"`
---
### Requirement: Real-time block onset detection
`BeatDetector.analyzeBlock(block, ctx)` SHALL accept a single `Float32Array[]` block plus a `BlockContext` and return a `BeatResult` synchronously, carrying instantaneous onset state for visualisation. It SHALL be allocation-light enough for per-block real-time use, SHALL NOT block on document-wide work, and SHALL carry a `confidence` in `[0, 1]` reflecting the current running tempo estimate. Calling `analyzeBlock` SHALL NOT require a prior `analyze` call.
#### Scenario: Block analysis returns instantaneous onset state
- **WHEN** `analyzeBlock` is called on a block containing a sharp transient
- **THEN** it returns a `BeatResult` synchronously whose `onsets` includes a timestamp within the block window and whose `confidence` is in `[0, 1]`
**Verified by:** `src/detector/beat-detector.test.ts::"analyzeBlock returns instantaneous onset state synchronously"`
---
### Requirement: AbortSignal propagation
`BeatDetector.analyze()` and `BeatDetector.analyzeStream()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisBeatError` with code `AUDIO_ANALYSIS_BEAT.ABORTED` before any page or FFT work begins; a signal aborted mid-scan SHALL terminate the generator (or reject the promise) at the next block boundary with the same error.
#### Scenario: Pre-aborted signal short-circuits analysis
- **WHEN** `analyze` is called with an already-aborted signal
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.ABORTED"` before reading any page
#### Scenario: Mid-scan abort terminates at the next block boundary
- **WHEN** a signal passed to `analyzeStream` is aborted after the first yield
- **THEN** the generator throws `AUDIO_ANALYSIS_BEAT.ABORTED` at the next block boundary and yields nothing further
**Verified by:** `src/detector/beat-detector.test.ts::"analyze throws AUDIO_ANALYSIS_BEAT.ABORTED for a pre-aborted signal"` AND `src/detector/beat-detector.test.ts::"analyzeStream honours mid-scan abort"`
---
### Requirement: Zod boundary validation
The public options and result objects — `BeatDetectorOptions` and `BeatResult` — SHALL each have a Zod schema exported from a sibling `*.schemas.ts` (`BeatDetectorOptionsSchema`, `BeatResultSchema`). Every public entry point (`constructor`, `analyze`, `analyzeStream`, `analyzeBlock`) SHALL validate its options argument against `BeatDetectorOptionsSchema` before use. A validation failure — including `minBpm` greater than `maxBpm`, a non-positive BPM bound, or an unknown `algorithm` — SHALL throw `AudioAnalysisBeatError` with code `AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `analyze` is called with `{ minBpm: 200, maxBpm: 60 }`
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"` with the Zod issues in `context`
#### Scenario: Unknown algorithm is rejected
- **WHEN** `BeatDetector` is constructed with `{ algorithm: "fourier-magic" }`
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"`
**Verified by:** `src/detector/beat-detector.test.ts::"rejects minBpm above maxBpm with AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"` AND `src/detector/beat-detector.test.ts::"constructor rejects an unknown algorithm"`
---
### Requirement: Structured error codes
The package SHALL export `AudioAnalysisBeatErrorCode`, `AUDIO_ANALYSIS_BEAT_ERROR_METADATA`, and `registerAudioAnalysisBeatErrorCodes()`, registering the codes `AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS`, `AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT`, `AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED`, and `AUDIO_ANALYSIS_BEAT.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisBeatError` extending `SuaveplanError` carrying one of these codes plus `cause` and `context`. An `analyze` call on a zero-length document SHALL throw `AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT`; a scan that finds no onsets above the detection threshold SHALL throw `AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED` rather than returning a zero-confidence result. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: Empty document throws AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT
- **WHEN** `analyze` is called on a document whose `length` is `0`
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT"`
#### Scenario: A document with no detectable onsets throws NO_BEATS_DETECTED
- **WHEN** `analyze` runs over a pure-silence document
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED"`
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ANALYSIS_BEAT_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/detector/beat-detector.test.ts::"analyze throws AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT for a zero-length document"` AND `src/detector/beat-detector.test.ts::"silence throws AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED"` AND `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span `audio.analysis.beat` around `analyze` / `analyzeStream`, a counter `audio.analysis.beat.runs_total` incremented per completed scan, and a histogram `audio.analysis.beat.duration_ms` recording scan wall-time. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: A scan records a span and histogram when an exporter is registered
- **WHEN** `analyze` runs with an exporter registered
- **THEN** an `audio.analysis.beat` span is recorded, `audio.analysis.beat.runs_total` increments by one, and `audio.analysis.beat.duration_ms` records a sample
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/detector/beat-detector.test.ts::"analyze records a span, counter, and histogram when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module — `./detector/beat-detector`, `./algorithms/energy-odf`, `./algorithms/spectral-flux`, `./algorithms/comb-tempo`, `./algorithms/beat-grid`, and `./error-codes` — in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-analysis-beat/algorithms/comb-tempo`
- **THEN** the import resolves to the comb-tempo module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`

View File

@@ -0,0 +1,247 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-analysis-loudness
**Package**: `@suaveplan/audio-analysis-loudness`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-analysis-loudness` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `AnalyserNode`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All loudness measurement SHALL operate on `Float32Array[]` PCM obtained from an `AudioDocument` through `@suaveplan/audio-core` read/stream APIs, with no platform-specific code paths. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace and zod"`
---
### Requirement: LoudnessMeter implements the AnalysisOp contract
The package SHALL export a `LoudnessMeter` class implementing the `AnalysisOp` interface defined by `@suaveplan/audio-core` (plugin slot 3 — Analysis Op). It SHALL expose a readonly `name` of `"loudness-meter"` and a readonly `description` of `"ITU-R BS.1770-4 loudness measurement"`, and SHALL implement `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`, and `analyzeBlock(block, ctx)`. The meter SHALL be a sidecar processor — it SHALL NOT register a plan or process op and SHALL NOT participate in the render pipeline.
#### Scenario: Meter is shaped as an AnalysisOp
- **WHEN** a caller constructs `new LoudnessMeter()`
- **THEN** `meter.name === "loudness-meter"`, `meter.description` is the BS.1770-4 string, and `analyze`, `analyzeStream`, and `analyzeBlock` are callable functions
#### Scenario: analyze resolves a LoudnessResult for a full document
- **WHEN** a caller `await meter.analyze(doc)` on a populated `AudioDocument`
- **THEN** the resolved value carries `op === "loudness-meter"`, a numeric `lufs`, `loudnessRange`, `truePeak`, `rms`, `peak`, and a `dcOffset` array of one entry per channel
**Verified by:** `src/meter/loudness-meter.test.ts::"exposes the AnalysisOp name and description"` AND `src/meter/loudness-meter.test.ts::"analyze resolves a fully populated LoudnessResult"`
---
### Requirement: ITU-R BS.1770-4 integrated loudness
`LoudnessMeter.analyze` SHALL compute integrated loudness (LUFS) per ITU-R BS.1770-4: each channel SHALL be passed through the K-weighting filter (stage 1 high-shelf pre-filter, stage 2 high-pass RLB filter), mean-square energy SHALL be accumulated over 400 ms gating blocks overlapped by 75%, channel energies SHALL be summed with the BS.1770 channel weights, the absolute gate SHALL discard blocks below −70 LUFS, and the relative gate SHALL discard blocks more than 10 LU below the ungated mean before the final integration. Momentary loudness SHALL use a 400 ms window and short-term loudness a 3 s window.
#### Scenario: Reference signal yields the BS.1770 expected loudness
- **WHEN** `analyze` runs on a −20 dBFS 1 kHz sine (mono, ≥ 3 s)
- **THEN** `result.lufs` is within ±0.1 LU of the BS.1770 reference value for that signal
#### Scenario: Quiet blocks are excluded by the gates
- **WHEN** `analyze` runs on a signal that is loud for 3 s then silent for 30 s
- **THEN** `result.lufs` reflects only the loud segment because the silent gating blocks fall below the absolute and relative gates
**Verified by:** `src/algorithms/lufs.test.ts::"integrated loudness matches the BS.1770 reference within tolerance"` AND `src/algorithms/lufs.test.ts::"absolute and relative gates discard quiet blocks"`
---
### Requirement: Loudness range per EBU Tech 3342
`LoudnessResult.loudnessRange` SHALL be the loudness range (LRA) in LU computed per EBU Tech 3342: short-term loudness values SHALL be collected over 3 s windows with 1 s steps, an absolute gate at −70 LUFS and a relative gate 20 LU below the gated mean SHALL be applied, and the LRA SHALL be the difference between the 95th and 10th percentiles of the surviving distribution. A document with fewer than one full short-term window SHALL report `loudnessRange` as `0`.
#### Scenario: Dynamic signal reports a positive loudness range
- **WHEN** `analyze` runs on a signal alternating between loud and quiet passages
- **THEN** `result.loudnessRange` is greater than `0` and equals the 95th-minus-10th percentile spread of the gated short-term distribution
#### Scenario: Too-short document reports zero range
- **WHEN** `analyze` runs on a document shorter than one 3 s short-term window
- **THEN** `result.loudnessRange === 0`
**Verified by:** `src/algorithms/loudness-range.test.ts::"reports the percentile spread for a dynamic signal"` AND `src/algorithms/loudness-range.test.ts::"reports zero range for a sub-window document"`
---
### Requirement: True-peak detection via 4x oversampling
`LoudnessResult.truePeak` SHALL be the true-peak level in dBTP measured by 4x oversampling each channel through a polyphase FIR interpolation filter and taking the maximum absolute interpolated sample across all channels, per ITU-R BS.1770-4 Annex 2. `truePeak` SHALL always be greater than or equal to the sample `peak` for the same signal, because inter-sample peaks can exceed sample maxima.
#### Scenario: True peak is at least the sample peak
- **WHEN** `analyze` runs on any non-silent signal
- **THEN** `result.truePeak >= result.peak`
#### Scenario: Inter-sample peak is detected above the sample maximum
- **WHEN** `analyze` runs on a signal crafted so the true waveform peak falls between samples (e.g. a 0 dBFS sine near Nyquist/4)
- **THEN** `result.truePeak` exceeds `result.peak` by a measurable margin
**Verified by:** `src/algorithms/true-peak.test.ts::"true peak is never below the sample peak"` AND `src/algorithms/true-peak.test.ts::"detects an inter-sample peak above the sample maximum"`
---
### Requirement: RMS, peak, and per-channel DC offset
`LoudnessResult` SHALL carry `rms` (root-mean-square level in dB across all channels), `peak` (maximum absolute sample expressed in dB), and `dcOffset` (an array holding the mean sample value of each channel). For a full-scale signal `peak` SHALL be `0` dB; for digital silence `rms` SHALL be reported as a finite floor value rather than `-Infinity` and `lufs` SHALL be reported as the BS.1770 silence floor without throwing.
#### Scenario: Known signal reports expected RMS, peak, and DC offset
- **WHEN** `analyze` runs on a unity-amplitude sine with no DC component
- **THEN** `result.peak` is `0` dB, `result.rms` is approximately `-3.01` dB, and every entry of `result.dcOffset` is approximately `0`
#### Scenario: Silence is handled gracefully
- **WHEN** `analyze` runs on a fully silent document
- **THEN** the call resolves without throwing, `result.lufs` is the BS.1770 silence floor, and `result.rms` is a finite floor value
**Verified by:** `src/meter/loudness-meter.test.ts::"reports expected RMS, peak, and DC offset for a known signal"` AND `src/meter/loudness-meter.test.ts::"handles silence without throwing or returning -Infinity"`
---
### Requirement: Streaming integrated loudness
`LoudnessMeter.analyzeStream` SHALL be an async generator that consumes `AudioDocument` blocks via `@suaveplan/audio-core` streaming and yields a `LoudnessResult` after each gating block, where each yielded result's `lufs` is the integrated loudness of all audio seen so far. The running gate state SHALL be carried across yields so the final yielded result equals what `analyze` would return for the whole document. The generator SHALL accept an `AbortSignal` via its options object.
#### Scenario: Final streamed result equals the batch result
- **WHEN** a caller fully drains `analyzeStream(doc)` and also calls `analyze(doc)` on the same document
- **THEN** the last yielded result's `lufs` equals the batch `analyze` result's `lufs` within floating-point tolerance
#### Scenario: Running loudness refines as blocks arrive
- **WHEN** a caller reads the first two yields of `analyzeStream` on a multi-block document
- **THEN** each yield carries an integrated `lufs` computed over strictly more audio than the previous yield
**Verified by:** `src/meter/loudness-meter.test.ts::"final streamed result equals the batch analyze result"` AND `src/meter/loudness-meter.test.ts::"streamed integrated loudness refines as blocks arrive"`
---
### Requirement: Block-level momentary metering
`LoudnessMeter.analyzeBlock(block, ctx)` SHALL compute momentary loudness for a single `Float32Array[]` block using the `BlockContext` sample rate and channel count, returning a `Partial<LoudnessResult>` carrying at least `lufs` (momentary), `peak`, and `rms` for that block. `analyzeBlock` SHALL be synchronous, allocation-light, and safe to call on every audio callback for real-time meters; it SHALL NOT mutate the input block.
#### Scenario: analyzeBlock returns momentary loudness for one block
- **WHEN** a caller calls `analyzeBlock` with a 400 ms block of a known-level signal
- **THEN** the returned partial carries a `lufs` value within tolerance of the BS.1770 momentary loudness for that block and leaves the input arrays unmodified
**Verified by:** `src/meter/loudness-meter.test.ts::"analyzeBlock returns momentary loudness without mutating the input"`
---
### Requirement: K-weighting filter
The package SHALL export a K-weighting filter implementation comprising the ITU-R BS.1770 two-stage biquad cascade (stage 1: high-shelf "pre-filter" modelling the acoustic effect of the head; stage 2: RLB high-pass filter). The filter coefficients SHALL be derived for the document's sample rate so measurement is correct at 44.1 kHz, 48 kHz, and other rates, and the filter SHALL maintain per-channel state so it can be applied incrementally to streamed blocks.
#### Scenario: Filter coefficients adapt to the sample rate
- **WHEN** the K-weighting filter is built for 44100 Hz and again for 48000 Hz
- **THEN** the two coefficient sets differ and each reproduces the BS.1770 reference magnitude response for its rate within tolerance
#### Scenario: Incremental filtering matches whole-buffer filtering
- **WHEN** a signal is filtered in one pass and again block-by-block through the stateful filter
- **THEN** the two filtered outputs are sample-equal within floating-point tolerance
**Verified by:** `src/filters/k-weighting.test.ts::"derives rate-specific coefficients matching the BS.1770 response"` AND `src/filters/k-weighting.test.ts::"incremental block filtering equals whole-buffer filtering"`
---
### Requirement: Zod boundary validation
Every public options object — `LoudnessMeterOptions` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point (`analyze`, `analyzeStream`, the `LoudnessMeter` constructor) SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioAnalysisLoudnessError` with code `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `new LoudnessMeter({ overlap: 2 })` is constructed with an out-of-range `overlap`
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/meter/loudness-meter.test.ts::"constructor rejects invalid options with AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL export `AudioAnalysisLoudnessErrorCode`, `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA`, and `registerAudioAnalysisLoudnessErrorCodes()`, registering the codes `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS`, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT`, and `AUDIO_ANALYSIS_LOUDNESS.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisLoudnessError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests: `AUDIO_ANALYSIS_LOUDNESS.INVALID_OPTIONS` from Zod validation, `AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT` from `analyze` on a zero-length document, and `AUDIO_ANALYSIS_LOUDNESS.ABORTED` from a pre-aborted or mid-stream-aborted signal.
#### Scenario: Empty document is rejected
- **WHEN** `analyze` is called on an `AudioDocument` with `sourceLength === 0`
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT"`
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/meter/loudness-meter.test.ts::"analyze throws AUDIO_ANALYSIS_LOUDNESS.EMPTY_DOCUMENT for a zero-length document"` AND `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"`
---
### Requirement: AbortSignal propagation
`LoudnessMeter.analyze` and `LoudnessMeter.analyzeStream` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisLoudnessError` with code `AUDIO_ANALYSIS_LOUDNESS.ABORTED` before any block is read; a signal aborted mid-stream SHALL terminate `analyzeStream` at the next gating-block boundary with the same error.
#### Scenario: Pre-aborted signal short-circuits analysis
- **WHEN** `analyze` is called with an already-aborted signal
- **THEN** it throws `AudioAnalysisLoudnessError` whose `code === "AUDIO_ANALYSIS_LOUDNESS.ABORTED"` before reading any audio block
#### Scenario: Mid-stream abort terminates at the next block boundary
- **WHEN** a signal is aborted after the first `analyzeStream` yield
- **THEN** the generator throws `AUDIO_ANALYSIS_LOUDNESS.ABORTED` at the next gating-block boundary and yields nothing further
**Verified by:** `src/meter/loudness-meter.test.ts::"analyze throws AUDIO_ANALYSIS_LOUDNESS.ABORTED for a pre-aborted signal"` AND `src/meter/loudness-meter.test.ts::"analyzeStream honours mid-stream abort"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span `audio.analysis.loudness` around `analyze` and `analyzeStream`, a counter `audio_analysis_runs_total` tagged `{op="loudness-meter"}`, and histograms `audio_analysis_duration_ms` and `audio_analysis_block_duration_ms` tagged `{op="loudness-meter"}`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and `analyze` is run with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: A successful analyze records the expected signals
- **WHEN** `analyze` runs against a real in-memory `MeterProvider`
- **THEN** `audio_analysis_runs_total{op="loudness-meter"}` increments by 1 AND `audio_analysis_duration_ms{op="loudness-meter"}` records a single observation
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/meter/loudness-meter.test.ts::"analyze records a span, counter, and histogram when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./meter/loudness-meter`, `./algorithms/lufs`, `./algorithms/true-peak`, `./algorithms/loudness-range`, `./filters/k-weighting`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-analysis-loudness/algorithms/true-peak`
- **THEN** the import resolves to the true-peak module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,183 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-analysis-pitch (audio-analysis-pitch)
**Package**: `@suaveplan/audio-analysis-pitch`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-analysis-pitch` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `AnalyserNode`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All environment-specific behaviour SHALL be reached only through `AudioDocument` and injected adapters. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`. The package SHALL ship its own pitch-domain FFT/autocorrelation so it has no hard dependency on `@suaveplan/audio-analysis-spectrum`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
---
### Requirement: PitchDetector implements the AnalysisOp contract
The package SHALL export a `PitchDetector` class implementing the `AnalysisOp` interface from `@suaveplan/audio-core` (plugin slot 3, Analysis Op). It SHALL expose a readonly `name` of `"pitch-detector"`, a readonly `description`, and the three methods `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`, and `analyzeBlock(block, ctx)`. `analyze` SHALL return a `Promise<PitchResult>` aggregated over the whole document; `analyzeStream` SHALL be an `AsyncGenerator<PitchResult>` whose `confidence` field is non-decreasing in expectation as more audio is consumed; `analyzeBlock` SHALL return a synchronous `PitchResult` for a single `Float32Array[]` block suitable for real-time visualisation. As an Analysis Op the detector SHALL run as a sidecar scan and SHALL NOT mutate the `AudioDocument` or participate in its render pipeline.
#### Scenario: analyze returns an aggregate PitchResult
- **WHEN** a caller `await new PitchDetector().analyze(doc)` on a document containing a steady tone
- **THEN** the resolved `PitchResult` carries `op === "pitch-detector"`, a `pitch` in Hz, a `midiNote`, a `confidence` in `[0, 1]`, and `timestamp`/`duration` covering the analysed range
#### Scenario: analyzeBlock is synchronous and non-mutating
- **WHEN** a caller calls `analyzeBlock(block, ctx)` on a single block
- **THEN** a `PitchResult` is returned synchronously, the input `block` arrays are unmodified, and no `AudioDocument` state is touched
**Verified by:** `src/detector/pitch-detector.test.ts::"analyze returns an aggregate PitchResult for a steady tone"` AND `src/detector/pitch-detector.test.ts::"analyzeBlock returns a synchronous result without mutating input"`
---
### Requirement: Fundamental-frequency estimation algorithms
`PitchDetector` SHALL accept `PitchDetectorOptions` with `algorithm` (`"yin" | "mcleod" | "cepstrum"`, default `"yin"`), `minFreq` (default `20`), and `maxFreq` (default `20000`). It SHALL estimate the fundamental frequency of the dominant periodic component within `[minFreq, maxFreq]` and report it on `PitchResult.pitch`, with the corresponding `midiNote` derived as `69 + 12 * log2(pitch / 440)` rounded to the nearest integer. When the input has no detectable periodicity above the algorithm's confidence floor (e.g. silence or broadband noise), `pitch` SHALL be `0`, `midiNote` SHALL be `-1`, and `confidence` SHALL be `0`.
#### Scenario: Known sine frequency is estimated within tolerance
- **WHEN** `analyze` runs on a synthesised 440 Hz sine at 48 kHz with the default `"yin"` algorithm
- **THEN** `pitch` is within ±1 Hz of `440`, `midiNote` is `69`, and `confidence` is greater than `0.9`
#### Scenario: Silence yields no pitch
- **WHEN** `analyze` runs on a fully silent document
- **THEN** `pitch` is `0`, `midiNote` is `-1`, and `confidence` is `0`
#### Scenario: Algorithm selection is honoured
- **WHEN** `analyze` is called with `{ algorithm: "mcleod" }` and again with `{ algorithm: "cepstrum" }` on the same 440 Hz tone
- **THEN** each run estimates `pitch` within ±2 Hz of `440` using the selected estimator
**Verified by:** `src/algorithms/yin.test.ts::"estimates a 440 Hz sine within one hertz"` AND `src/detector/pitch-detector.test.ts::"silence yields zero pitch and zero confidence"` AND `src/detector/pitch-detector.test.ts::"honours the algorithm option for mcleod and cepstrum"`
---
### Requirement: KeyDetector implements the AnalysisOp contract
The package SHALL export a `KeyDetector` class implementing the `AnalysisOp` interface (plugin slot 3, Analysis Op). It SHALL expose a readonly `name` of `"key-detector"`, a readonly `description`, and the methods `analyze(doc, opts?)` and `analyzeStream(doc, opts?)`. `analyze` SHALL return a `Promise<KeyResult>` and `analyzeStream` SHALL be an `AsyncGenerator<KeyResult>` whose estimate refines as more audio is consumed. The detector SHALL accumulate a 12-bin chromagram over the analysed range and SHALL NOT mutate the `AudioDocument`.
#### Scenario: analyze returns a KeyResult with a 12-bin chromagram
- **WHEN** a caller `await new KeyDetector().analyze(doc)` on a tonal document
- **THEN** the resolved `KeyResult` carries `op === "key-detector"`, a `key` string, a `confidence` in `[0, 1]`, and a `chromagram` `Float32Array` of length `12`
**Verified by:** `src/detector/key-detector.test.ts::"analyze returns a KeyResult with a length-12 chromagram"`
---
### Requirement: Key and chord estimation outputs
`KeyDetector` SHALL accept `KeyDetectorOptions` with `profile` (`"krumhansl-schmuckler" | "temperley"`, default `"krumhansl-schmuckler"`). It SHALL correlate the aggregated chromagram against all 24 major/minor key profiles for the selected weighting and report the best-scoring key on `KeyResult.key` as a pitch-class name plus mode (e.g. `"C major"`, `"F# minor"`), with the normalised correlation margin on `KeyResult.confidence`. The package SHALL additionally export a `ChordDetector` `AnalysisOp` (`name === "chord-detector"`) whose `analyze` returns a `Promise<ChordResult>` carrying a time-ordered `chords` array of `Chord` records (`{ timestamp, duration, root, quality, confidence }`) produced by template-matching the per-frame chromagram against major/minor/diminished/augmented templates with temporal smoothing so transient frames do not produce spurious chords.
#### Scenario: A C-major signal is identified as C major
- **WHEN** `KeyDetector.analyze` runs on a document built from a C-major triad/scale with the default profile
- **THEN** `key` equals `"C major"` and `confidence` is greater than `0.6`
#### Scenario: Key profile selection is honoured
- **WHEN** `analyze` is called with `{ profile: "temperley" }`
- **THEN** the Temperley weighting is used to score the chromagram and the returned `key` reflects that profile
#### Scenario: Chord progression is time-ordered and smoothed
- **WHEN** `ChordDetector.analyze` runs on a document with a C-major then G-major passage
- **THEN** `chords` is ordered by ascending `timestamp`, each `Chord.root`/`quality` names the matched chord, no `Chord` is shorter than the smoothing window, and adjacent identical chords are merged
**Verified by:** `src/algorithms/key-finding.test.ts::"identifies a C-major signal as C major"` AND `src/detector/key-detector.test.ts::"honours the temperley profile option"` AND `src/algorithms/chord-matching.test.ts::"emits a time-ordered smoothed chord progression"`
---
### Requirement: Streaming refinement and AbortSignal propagation
`PitchDetector.analyzeStream`, `KeyDetector.analyzeStream`, and `ChordDetector.analyzeStream` SHALL accept an `AbortSignal` via their options object and yield progressively refined `AnalysisResult` values as document blocks are consumed. An already-aborted signal SHALL cause the generator to throw `AudioAnalysisError` with code `AUDIO_ANALYSIS_PITCH.ABORTED` before any block work; a signal aborted mid-stream SHALL terminate the generator at the next block boundary with the same error. `analyze` SHALL likewise honour an `AbortSignal` and reject with `AUDIO_ANALYSIS_PITCH.ABORTED`.
#### Scenario: Pre-aborted signal short-circuits the stream
- **WHEN** `analyzeStream` is called with an already-aborted signal
- **THEN** it throws `AudioAnalysisError` whose `code === "AUDIO_ANALYSIS_PITCH.ABORTED"` before yielding any result
#### Scenario: Mid-stream abort terminates at the next block boundary
- **WHEN** a signal is aborted after the first partial result is yielded
- **THEN** the generator throws `AUDIO_ANALYSIS_PITCH.ABORTED` at the next block boundary and yields nothing further
**Verified by:** `src/detector/pitch-detector.test.ts::"analyzeStream throws AUDIO_ANALYSIS_PITCH.ABORTED for a pre-aborted signal"` AND `src/detector/key-detector.test.ts::"analyzeStream honours a mid-stream abort"`
---
### Requirement: Zod boundary validation
Every public options object — `PitchDetectorOptions` and `KeyDetectorOptions` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and `PitchResult`, `KeyResult`, and `ChordResult` SHALL each have a Zod schema for output validation. Every public entry point (`analyze`, `analyzeStream`, `analyzeBlock`) SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioAnalysisError` with code `AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `PitchDetector.analyze` is called with `{ minFreq: -1 }` or an unknown `algorithm`
- **THEN** it throws `AudioAnalysisError` whose `code === "AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/detector/pitch-detector.test.ts::"rejects invalid options with AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL export `AudioAnalysisPitchErrorCode`, `AUDIO_ANALYSIS_PITCH_ERROR_METADATA`, and `registerAudioAnalysisPitchErrorCodes()`, registering the codes `AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS`, `AUDIO_ANALYSIS_PITCH.ABORTED`, `AUDIO_ANALYSIS_PITCH.UNSUPPORTED_FORMAT`, and `AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. `AUDIO_ANALYSIS_PITCH.UNSUPPORTED_FORMAT` SHALL be thrown when a document's channel layout or sample rate cannot be analysed; `AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA` SHALL be thrown when the analysed range is shorter than the minimum window an algorithm requires. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ANALYSIS_PITCH_ERROR_METADATA` is asserted thrown by at least one test
#### Scenario: Too-short input throws AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA
- **WHEN** `PitchDetector.analyze` runs on a document shorter than one analysis window
- **THEN** it throws `AudioAnalysisError` whose `code === "AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA"`
**Verified by:** `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"` AND `src/detector/pitch-detector.test.ts::"throws AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA for a too-short document"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span per detector run (`audio.analysis.pitch`, `audio.analysis.key`, `audio.analysis.chord`), a counter `audio_analysis_runs_total` tagged with `op`, and histograms `audio_analysis_duration_ms` and `audio_analysis_block_duration_ms` tagged with `op`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: A detector run records a span and increments the counter
- **WHEN** `PitchDetector.analyze` runs with an exporter registered
- **THEN** an `audio.analysis.pitch` span is recorded, `audio_analysis_runs_total{op="pitch-detector"}` is incremented, and `audio_analysis_duration_ms` observes the run
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/detector/pitch-detector.test.ts::"analyze records a span, counter, and histogram when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./detector/pitch-detector`, `./detector/key-detector`, `./detector/chord-detector`, `./algorithms/yin`, `./algorithms/chromagram`, `./algorithms/key-finding`, `./algorithms/chord-matching`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-analysis-pitch/algorithms/yin`
- **THEN** the import resolves to the YIN module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`

View File

@@ -0,0 +1,242 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-analysis-spectrum (audio-analysis-spectrum)
**Package**: `@suaveplan/audio-analysis-spectrum`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-analysis-spectrum` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). FFT, windowing, mel-filterbank, and DCT SHALL be implemented in portable TypeScript over `Float32Array`; any platform-accelerated path (e.g. a browser `AnalyserNode`) SHALL be reached only through an injected adapter, never a direct global reference. The package's runtime `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
---
### Requirement: AnalysisOp conformance
`SpectrumAnalyzer`, `Spectrogram`, and `MFCC` SHALL each implement the `AnalysisOp` contract from `@suaveplan/audio-core` (plugin slot 3): a readonly `name` (`"spectrum-analyzer"`, `"spectrogram"`, `"mfcc"` respectively), a readonly `description`, an async `analyze(doc, opts?)`, an `analyzeStream(doc, opts?)` async generator, and a synchronous `analyzeBlock(block, ctx)`. Every emitted result SHALL extend `AnalysisResult` — carrying `op`, `timestamp`, `duration`, `features`, and optional `confidence` — so the three analyzers register interchangeably as analysis ops. Analysis SHALL run as a sidecar scan and SHALL NOT mutate the input `AudioDocument` or its edit list.
#### Scenario: Each analyzer registers as an AnalysisOp
- **WHEN** a caller assigns `new SpectrumAnalyzer()`, `new Spectrogram()`, and `new MFCC()` to `AnalysisOp` slots
- **THEN** each exposes `name`, `description`, `analyze`, `analyzeStream`, and `analyzeBlock`, and `analyze` resolves to a value extending `AnalysisResult`
#### Scenario: Analysis leaves the document unchanged
- **WHEN** `SpectrumAnalyzer.analyze(doc)` completes
- **THEN** `doc.version` and `doc.edits` are unchanged from before the call
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"implements the AnalysisOp contract"` AND `tests/integration.test.ts::"analysis does not mutate the source document"`
---
### Requirement: Window functions
The package SHALL export a `window` module providing Hann, Hamming, and Blackman window generators selectable via the `window` option (`'hann'` default, `'hamming'`, `'blackman'`). A generator SHALL return a `Float32Array` of the requested frame size whose coefficients match the closed-form definition of the window to within `1e-6`, and SHALL be applied multiplicatively to each frame before the FFT. Window arrays SHALL be cached per `(type, size)` so repeated frames of the same shape reuse one allocation.
#### Scenario: Hann window matches its closed form
- **WHEN** a caller requests a Hann window of size `2048`
- **THEN** every coefficient equals `0.5 - 0.5 * cos(2πn / (N-1))` to within `1e-6`, the endpoints are `0`, and the centre is `1`
#### Scenario: Unknown window name is rejected
- **WHEN** `analyze` is called with `window` set to a value outside `'hann' | 'hamming' | 'blackman'`
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/algorithms/window.test.ts::"Hann window matches the closed form"` AND `src/analyzer/spectrum-analyzer.test.ts::"rejects an unknown window name with AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"`
---
### Requirement: Real FFT over power-of-two frames
The package SHALL export an `fft` module computing the real-input FFT of a windowed frame, returning per-bin magnitude (and phase where requested) for `frameSize / 2 + 1` bins. `frameSize` SHALL be a power of two between `256` and `32768` inclusive; a non-power-of-two or out-of-range `frameSize` SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME`. The transform SHALL be deterministic — the same input frame SHALL always yield byte-identical magnitude output — and SHALL allocate its twiddle-factor tables once per `frameSize`.
#### Scenario: Single-frequency input produces a single magnitude peak
- **WHEN** a frame containing a pure sinusoid at a bin-centre frequency is transformed
- **THEN** the magnitude spectrum has one dominant bin at that frequency and all other bins are near zero
#### Scenario: Non-power-of-two frame size is rejected
- **WHEN** `fft` is asked to transform a frame whose size is not a power of two
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME"` with the offending size in `context`
**Verified by:** `src/algorithms/fft.test.ts::"pure sinusoid yields a single magnitude peak"` AND `src/algorithms/fft.test.ts::"rejects a non-power-of-two frame size with AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME"`
---
### Requirement: SpectrumAnalyzer magnitude spectrum and spectral features
`SpectrumAnalyzer.analyze(doc, opts?)` SHALL slice `doc` into overlapping frames of `frameSize` samples advanced by `hopSize`, apply the configured window, run the real FFT, and reduce the per-bin magnitudes into a `SpectrumResult` carrying a `spectrum: Float32Array` of `bins` magnitude values plus the scalar spectral features `centroid`, `rolloff`, and `flux`. `bins` (default `128`), `frameSize` (default `2048`), and `hopSize` (default `512`) SHALL be caller-configurable. `centroid` SHALL be the magnitude-weighted mean frequency, `rolloff` the frequency below which 85% of spectral energy lies, and `flux` the summed positive frame-to-frame magnitude difference. `analyzeBlock(block, ctx)` SHALL compute the same `SpectrumResult` for a single real-time block using `ctx.sampleRate`.
#### Scenario: Sine input centroid tracks the tone frequency
- **WHEN** `analyze` runs on a document containing a 1 kHz sine at 48 kHz
- **THEN** `result.centroid` is within one bin width of 1 kHz and `result.spectrum` has its peak at the corresponding bin
#### Scenario: analyzeBlock yields a SpectrumResult for one block
- **WHEN** `analyzeBlock` is called with a single `Float32Array[]` block and a `BlockContext`
- **THEN** it returns a `SpectrumResult` whose `op === "spectrum-analyzer"` and whose `spectrum` length equals the configured `bins`
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"centroid tracks a pure tone frequency"` AND `src/analyzer/spectrum-analyzer.test.ts::"analyzeBlock returns a SpectrumResult for a single block"`
---
### Requirement: Spectrogram time-frequency matrix
`Spectrogram.analyze(doc, opts?)` SHALL produce a `SpectrogramResult` whose `spectrogram: Float32Array[]` is the ordered list of per-frame magnitude spectra computed with the same `frameSize`/`hopSize`/`window` options as `SpectrumAnalyzer`. The number of frames SHALL equal `floor((doc.length - frameSize) / hopSize) + 1` for a document at least `frameSize` samples long, and each frame vector SHALL have `frameSize / 2 + 1` magnitude values. A document shorter than `frameSize` SHALL yield an empty `spectrogram` array rather than throwing.
#### Scenario: Frame count matches the hop arithmetic
- **WHEN** `analyze` runs on a document of known length with `frameSize` `2048` and `hopSize` `512`
- **THEN** `result.spectrogram.length === floor((doc.length - 2048) / 512) + 1`
#### Scenario: Document shorter than one frame yields an empty spectrogram
- **WHEN** `analyze` runs on a document with fewer than `frameSize` samples
- **THEN** `result.spectrogram` is an empty array and no error is thrown
**Verified by:** `src/analyzer/spectrogram.test.ts::"frame count matches the hop arithmetic"` AND `src/analyzer/spectrogram.test.ts::"short document yields an empty spectrogram"`
---
### Requirement: Mel filterbank
The package SHALL export a `mel-scale` module providing a hertz-to-mel mapping, its inverse, and a triangular mel filterbank builder. The builder SHALL produce `melBands` triangular filters evenly spaced on the mel scale across a `[minHz, maxHz]` range, each filter normalised so its triangular weights sum consistently, returned as a reusable matrix keyed by `(frameSize, sampleRate, melBands, minHz, maxHz)`. Applying the filterbank to a magnitude spectrum SHALL yield a `melBands`-length mel-spectrum vector. `melBands` SHALL be a positive integer; a non-positive value SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`.
#### Scenario: Filterbank maps a spectrum to the mel band count
- **WHEN** a 1025-bin magnitude spectrum is passed through a 40-band mel filterbank
- **THEN** the result is a `Float32Array` of length `40` with non-negative entries
#### Scenario: Mel mapping round-trips
- **WHEN** a frequency is converted hertz-to-mel and back
- **THEN** the recovered frequency equals the original to within `1e-3`
**Verified by:** `src/algorithms/mel-scale.test.ts::"filterbank maps a spectrum to the mel band count"` AND `src/algorithms/mel-scale.test.ts::"hertz-to-mel mapping round-trips"`
---
### Requirement: MFCC via DCT-II on the log mel-spectrum
The package SHALL export a `dct` module computing the type-II DCT, and an `MFCC` analyzer that, per frame, builds the magnitude spectrum, applies the mel filterbank, takes the natural log of the mel energies (floored to avoid `log(0)`), and runs DCT-II to produce `coefficients` cepstral values. `MFCC.analyze(doc, opts?)` SHALL return an `MFCCResult` whose `mfcc: Float32Array[]` holds one coefficient vector per frame. `coefficients` SHALL default to `13` and SHALL be a positive integer no greater than `melBands`; a value exceeding `melBands` SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`.
#### Scenario: DCT-II matches its closed form
- **WHEN** the `dct` module transforms a known input vector
- **THEN** the output equals the closed-form type-II DCT of that vector to within `1e-6`
#### Scenario: MFCC yields one coefficient vector per frame
- **WHEN** `MFCC.analyze` runs on a multi-frame document with `coefficients` `13`
- **THEN** `result.mfcc.length` equals the spectrogram frame count and every vector has length `13`
**Verified by:** `src/algorithms/dct.test.ts::"DCT-II matches the closed form"` AND `src/analyzer/mfcc.test.ts::"produces one 13-coefficient vector per frame"`
---
### Requirement: Streaming per-frame analysis
`SpectrumAnalyzer.analyzeStream`, `Spectrogram.analyzeStream`, and `MFCC.analyzeStream` SHALL each be async generators that consume `doc` block-by-block and yield a partial `AnalysisResult` per completed frame, so a visualiser can update in real time before the full scan finishes. The concatenation of streamed frame results SHALL be equivalent to the single-shot `analyze` output for the same document and options — same frame count, same per-frame vectors.
#### Scenario: Streamed frames equal the single-shot result
- **WHEN** a caller fully drains `Spectrogram.analyzeStream(doc, opts)` and collects every yielded frame
- **THEN** the collected frames are deep-equal to `Spectrogram.analyze(doc, opts)`'s `spectrogram`
**Verified by:** `src/analyzer/spectrogram.test.ts::"analyzeStream frames equal the single-shot analyze result"`
---
### Requirement: AbortSignal propagation
`analyze` and `analyzeStream` on all three analyzers SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.ABORTED` before any frame work; a signal aborted mid-scan SHALL terminate the generator at the next frame boundary with the same error and yield nothing further.
#### Scenario: Pre-aborted signal short-circuits the scan
- **WHEN** `analyze` is called with an already-aborted signal
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.ABORTED"` before processing any frame
#### Scenario: Mid-scan abort terminates at the next frame boundary
- **WHEN** a signal is aborted after the first streamed frame is yielded
- **THEN** the generator throws `AUDIO_ANALYSIS_SPECTRUM.ABORTED` at the next frame boundary and yields nothing further
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"analyze throws AUDIO_ANALYSIS_SPECTRUM.ABORTED for a pre-aborted signal"` AND `src/analyzer/spectrogram.test.ts::"analyzeStream honours mid-scan abort"`
---
### Requirement: Zod boundary validation
Every public options object — `SpectrumAnalyzerOptions` — and every emitted result type — `SpectrumResult`, `SpectrogramResult`, `MFCCResult` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point (`analyze`, `analyzeStream`, `analyzeBlock`) SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `SpectrumAnalyzer.analyze` is called with a negative `hopSize`
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"rejects invalid options with AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL export `AudioAnalysisSpectrumErrorCode`, `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA`, and `registerAudioAnalysisSpectrumErrorCodes()`, registering the codes `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`, `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME`, and `AUDIO_ANALYSIS_SPECTRUM.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisSpectrumError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span `audio.analysis.<op>` per `analyze`/`analyzeStream` invocation; a counter `audio.analysis.runs_total{op,format}`; a histogram `audio.analysis.duration_ms{op}`; and a histogram `audio.analysis.block_duration_ms{op}` for `analyzeBlock`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: A completed analyze records a span and duration histogram
- **WHEN** `SpectrumAnalyzer.analyze` runs against a real in-memory `MeterProvider`
- **THEN** `audio.analysis.runs_total{op="spectrum-analyzer"}` increments by 1 and `audio.analysis.duration_ms{op="spectrum-analyzer"}` records a single observation
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"records a span and duration histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./analyzer/spectrum-analyzer`, `./analyzer/spectrogram`, `./analyzer/mfcc`, `./algorithms/fft`, `./algorithms/window`, `./algorithms/mel-scale`, `./algorithms/dct`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-analysis-spectrum/algorithms/fft`
- **THEN** the import resolves to the FFT module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,216 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-cli (audio-cli)
**Package**: `@suaveplan/audio-cli`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Server-tier purity
The `@suaveplan/audio-cli` package SHALL be a server-tier application package: it MAY import Node APIs (`node:fs`, `node:path`, `node:process`, `node:stream`, `Buffer`) and MUST NOT import browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`). All audio processing SHALL be delegated to the universal `@suaveplan/audio-core`, `@suaveplan/audio-codecs`, and `@suaveplan/audio-engine-server` packages — the CLI itself SHALL contain no DSP, codec, or page-management logic. Its `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-codecs`, `@suaveplan/audio-engine-server`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-analysis-*`, `@suaveplan/cli`, `@suaveplan/queue`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no browser globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, or `AudioBuffer`
#### Scenario: No DSP logic lives in the CLI package
- **WHEN** the package source is audited
- **THEN** every audio transform is reached through an imported `@suaveplan/audio-*` symbol, and no `Float32Array` block-processing loop is implemented in `audio-cli`
**Verified by:** `tests/purity.test.ts::"dist references no browser globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace packages and zod"`
---
### Requirement: The `audio` CLI entry point and `bin` declaration
The package SHALL ship an executable entry module that registers the `audio` command-line tool. `package.json` SHALL declare `"bin": { "audio": "./dist/index.js" }` and the entry module SHALL begin with a `#!/usr/bin/env bun` shebang. The entry point SHALL construct an `AudioCLI` instance, register every subcommand, parse `process.argv`, dispatch the matched command, and call `process.exit` with the resolved exit code. The package SHALL also export the `AudioCLI` class from its root barrel for programmatic use without spawning a subprocess.
#### Scenario: Installed `bin` resolves and runs
- **WHEN** the package is installed and `audio --version` is invoked through the `bin` shim
- **THEN** the process prints the package version to stdout and exits `0`
#### Scenario: Programmatic AudioCLI mirrors the CLI
- **WHEN** a caller imports `AudioCLI` and calls `cli.process(input, output, opts)` directly
- **THEN** the produced output file is byte-identical to running the equivalent `audio <input> ... save <output>` invocation
**Verified by:** `tests/integration.test.ts::"audio --version runs through the bin shim and exits 0"` AND `src/cli.test.ts::"programmatic AudioCLI.process matches the CLI invocation"`
---
### Requirement: Argument and pipeline parsing
The package SHALL export a `parsePipeline(spec: string)` function that converts a Unix-pipe-style operation chain (e.g. `"trim 30s | normalize -16lufs | fade 0.5s 1s"`) and the positional bare-op form (`audio input.wav trim 30s normalize -16lufs save output.flac`) into an ordered `Array<[opName: string, params: Record<string, unknown>]>`. The parser SHALL support quoted arguments, backslash escaping, numeric literals, and unit suffixes (`s` seconds, `ms` milliseconds, `lufs`, `db`, `hz`). The resolved op chain SHALL map one-to-one onto `AudioEdit` records passed to `AudioDocument.run`. A chain referencing an op name not present in the composed `OpRegistry` SHALL throw `AudioCliError` with code `AUDIO_CLI.UNKNOWN_OP` naming the offending op.
#### Scenario: Pipe-string and bare-op forms produce the same edit chain
- **WHEN** `parsePipeline("trim 30s | normalize")` and the positional form `["trim", "30s", "normalize"]` are both parsed
- **THEN** both yield the same `[["trim", { start: 30 }], ["normalize", {}]]` edit chain
#### Scenario: Unknown op name is rejected
- **WHEN** a pipeline string names an op absent from the registry
- **THEN** parsing throws `AudioCliError` whose `code === "AUDIO_CLI.UNKNOWN_OP"` with the offending op name in `context`
**Verified by:** `src/pipeline/parser.test.ts::"pipe-string and bare-op forms parse to the same edit chain"` AND `src/pipeline/parser.test.ts::"rejects an unknown op name with AUDIO_CLI.UNKNOWN_OP"`
---
### Requirement: Input → ops chain → output processing flow
The `process` and bare-op commands SHALL execute the pipeline `input → ops chain → output`: load the input via `@suaveplan/audio-engine-server`'s `FileSourceAdapter` (or stdin), append the parsed ops as `AudioEdit` records to an `AudioDocument`, stream the compiled plan through `@suaveplan/audio-core`'s `PlanEngine` + `ProcessPipeline`, and encode the result to the output path via `@suaveplan/audio-codecs` and `FileSinkAdapter` (or stdout). The output codec SHALL be inferred from the output file extension; an unrecognised extension SHALL throw `AudioCliError` with code `AUDIO_CLI.BAD_OUTPUT`. Processing SHALL be streaming end-to-end — the CLI MUST NOT materialise the whole decoded file in memory when the codec supports progressive decode.
#### Scenario: Trim then normalize round-trips to a new format
- **WHEN** `audio input.wav trim 30s normalize -16lufs save output.flac` runs against a real temp WAV file
- **THEN** `output.flac` exists, decodes to a 30-second FLAC stream, and the process exits `0`
#### Scenario: Unrecognised output extension is rejected
- **WHEN** the output path carries an extension no registered codec claims
- **THEN** the command throws `AudioCliError` whose `code === "AUDIO_CLI.BAD_OUTPUT"` and exits non-zero
**Verified by:** `src/commands/process.test.ts::"trim then normalize writes a 30s FLAC and exits 0"` AND `src/commands/process.test.ts::"rejects an unrecognised output extension with AUDIO_CLI.BAD_OUTPUT"`
---
### Requirement: Streaming through audio-core, audio-codecs, and audio-engine-server
Every command that reads or writes audio SHALL compose the three lower packages without re-implementing their behaviour: `@suaveplan/audio-engine-server` for file/stdin/stdout adapters, `@suaveplan/audio-codecs` for decode/encode, and `@suaveplan/audio-core` for the `AudioDocument` + `PlanEngine` + `ProcessPipeline` render path. The `analyze` command SHALL additionally compose the `@suaveplan/audio-analysis-*` ops, and pipeline FX SHALL be resolved from `@suaveplan/audio-fx-plugin-base`. The CLI SHALL pass decoded `Float32Array[]` blocks through `@suaveplan/streams` so backpressure is honoured between decode, process, and encode stages.
#### Scenario: Decode, process, and encode stages are backpressure-linked
- **WHEN** a large input is processed and the sink stage is artificially slowed
- **THEN** the decode stage pauses rather than buffering the whole file, and peak memory stays bounded
#### Scenario: Analyze composes analysis ops without re-implementing them
- **WHEN** `audio analyze input.mp3 --bpm --key --lufs` runs
- **THEN** the report values are produced by the imported `@suaveplan/audio-analysis-*` ops, not by logic local to `audio-cli`
**Verified by:** `tests/integration.test.ts::"decode/process/encode stages honour backpressure"` AND `src/commands/analyze.test.ts::"analyze delegates to the audio-analysis ops"`
---
### Requirement: `--help` and `--version` flags
The CLI SHALL handle `--help` / `-h` and `--version` / `-V` at both the top level and per subcommand. `--version` SHALL print the `@suaveplan/audio-cli` package version to stdout and exit `0`. `--help` SHALL print usage, the command table, and per-command option descriptions to stdout and exit `0`. An unknown top-level command or an unknown flag SHALL print a short usage hint to stderr and exit with a non-zero code.
#### Scenario: Top-level help lists every command
- **WHEN** `audio --help` runs
- **THEN** stdout contains a usage line and an entry for each of `process`, `batch`, `analyze`, `convert`, `info`, `play`, and `record`, and the process exits `0`
#### Scenario: Unknown command prints usage and exits non-zero
- **WHEN** `audio frobnicate` runs
- **THEN** stderr carries a usage hint, stdout is empty, and the exit code is non-zero
**Verified by:** `src/cli.test.ts::"--help lists every registered command and exits 0"` AND `src/cli.test.ts::"unknown command prints usage to stderr and exits non-zero"`
---
### Requirement: Exit codes mapped from error codes
The CLI SHALL exit `0` on success and a documented non-zero code on failure. Every `AUDIO_CLI_*` error code SHALL map to a stable, non-zero exit code via an exported `EXIT_CODES` table: usage/argument errors → `2`, input-not-found / unreadable → `3`, bad-output / encode failure → `4`, pipeline / processing failure → `5`, aborted (SIGINT) → `130`. An uncaught error of unknown shape SHALL exit `1`. A failed command SHALL write a single human-readable diagnostic line to stderr carrying the error `code` and `message` before exiting.
#### Scenario: Missing input file exits with the input-error code
- **WHEN** `audio missing.wav normalize save out.wav` runs against a path that does not exist
- **THEN** the process exits `3`, stderr carries an `AUDIO_CLI.INPUT_NOT_FOUND` diagnostic, and no output file is created
#### Scenario: Every error code has a distinct non-zero exit mapping
- **WHEN** the `EXIT_CODES` table is enumerated
- **THEN** every `AUDIO_CLI_*` code maps to a non-zero integer and `0` is reserved for success
**Verified by:** `src/commands/process.test.ts::"missing input exits 3 with an AUDIO_CLI.INPUT_NOT_FOUND diagnostic"` AND `src/error-codes.test.ts::"every AUDIO_CLI code maps to a distinct non-zero exit code"`
---
### Requirement: Zod boundary validation of parsed arguments
Every command's resolved option object — `ProcessOptions`, `BatchOptions`, `AnalyzeOptions`, `ConvertOptions`, `InfoOptions`, `PlayOptions`, `RecordOptions` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and each command SHALL validate its parsed arguments against the schema before doing any I/O. A validation failure SHALL throw `AudioCliError` with code `AUDIO_CLI.INVALID_ARGS` carrying the Zod issue list in `context`, and the CLI SHALL surface it as exit code `2`.
#### Scenario: Invalid argument is rejected at the boundary
- **WHEN** `audio batch --workers -1 ...` is invoked with a negative worker count
- **THEN** the command throws `AudioCliError` whose `code === "AUDIO_CLI.INVALID_ARGS"` with the Zod issues in `context`, and the process exits `2`
**Verified by:** `src/commands/batch.test.ts::"rejects a negative --workers value with AUDIO_CLI.INVALID_ARGS and exit 2"`
---
### Requirement: Structured error codes
The package SHALL export `AudioCliErrorCode`, `AUDIO_CLI_ERROR_METADATA`, and `registerAudioCliErrorCodes()`, registering the codes `AUDIO_CLI.INVALID_ARGS`, `AUDIO_CLI.UNKNOWN_OP`, `AUDIO_CLI.INPUT_NOT_FOUND`, `AUDIO_CLI.BAD_OUTPUT`, `AUDIO_CLI.PIPELINE_FAILED`, and `AUDIO_CLI.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCliError` extending `SuaveplanError` carrying one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests running the real CLI against real temp files.
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_CLI_ERROR_METADATA` is asserted thrown by at least one test invoking the real CLI
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CLI code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its commands via `@suaveplan/opentelemetry`: a span per command named `audio_cli.<command>`, a counter `audio_cli_files_processed_total{command,outcome}`, a counter `audio_cli_commands_total{command,outcome}`, and a histogram `audio_cli_command_duration_ms{command,outcome}`. The `outcome` attribute SHALL be one of `success`/`error`/`aborted`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: A successful command records its span and counters
- **WHEN** `audio convert in.wav out.flac` runs against a real in-memory OTel provider
- **THEN** a span `audio_cli.convert` is recorded, `audio_cli_commands_total{command="convert",outcome="success"}` increments by 1, and `audio_cli_command_duration_ms` records one observation
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and a command runs with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
**Verified by:** `src/commands/convert.test.ts::"convert records a span and counters with an exporter registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: AbortSignal and SIGINT handling
The CLI SHALL install a `SIGINT` handler that aborts an `AbortController` whose signal is threaded into every long-running command (`process`, `batch`, `analyze`, `convert`, `record`) and into the underlying `audio-core` stream and `audio-engine-server` adapter calls. On abort the command SHALL stop at the next block boundary, clean up any partially written output file, throw `AudioCliError` with code `AUDIO_CLI.ABORTED`, and exit `130`. A second `SIGINT` SHALL force-exit immediately. The programmatic `AudioCLI` methods SHALL accept an `AbortSignal` via their options object so the same path is exercisable without a real signal.
#### Scenario: SIGINT aborts and removes the partial output
- **WHEN** a long `process` run receives `SIGINT` mid-stream
- **THEN** the command throws `AUDIO_CLI.ABORTED`, the partially written output file is removed, and the process exits `130`
#### Scenario: Pre-aborted signal short-circuits a programmatic call
- **WHEN** `AudioCLI.process` is called with an already-aborted signal
- **THEN** it throws `AudioCliError` whose `code === "AUDIO_CLI.ABORTED"` before opening the input file
**Verified by:** `tests/integration.test.ts::"SIGINT aborts a process run, removes partial output, exits 130"` AND `src/cli.test.ts::"AudioCLI.process short-circuits a pre-aborted signal"`
---
### Requirement: Subpath exports for reusable modules
`package.json` SHALL declare subpath `exports` for the reusable non-`bin` modules — `./cli` (the `AudioCLI` class), `./pipeline/parser` (`parsePipeline`), and `./commands/<command>` for each command's programmatic entry — in addition to the root `.` barrel and the `bin` entry. `sideEffects` SHALL be `false`. The shebang entry module SHALL NOT be re-exported from the root barrel so importing the package never triggers `process.argv` parsing.
#### Scenario: The pipeline parser is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-cli/pipeline/parser`
- **THEN** the import resolves to `parsePipeline` without pulling the command registry or the `bin` entry
#### Scenario: Importing the root barrel does not parse argv
- **WHEN** a consumer imports `@suaveplan/audio-cli`
- **THEN** no command dispatch runs and `process.argv` is not read at import time
**Verified by:** `tests/exports.test.ts::"every reusable module has a subpath export entry"` AND `tests/exports.test.ts::"importing the root barrel does not parse process.argv"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,273 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-codec-ffmpeg-native (audio-codec-ffmpeg-native)
**Package**: `@suaveplan/audio-codec-ffmpeg-native`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Server-tier purity
The `@suaveplan/audio-codec-ffmpeg-native` package SHALL be a server-tier package that imports zero browser globals (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and SHALL run only on Node-compatible runtimes (Node ≥ 20, Bun). The package's `package.json` SHALL NOT declare a `"browser"` field, SHALL declare `"engines": { "node": ">=20" }`, and SHALL import `node:child_process` only inside the `runner` module — not at any other module's top level. The `dependencies` SHALL be limited to `@suaveplan/audio-codecs`, `@suaveplan/audio-core`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `fluent-ffmpeg`, `zod`, `@opentelemetry/api`, and `tslib`; `@ffmpeg-installer/ffmpeg` SHALL be declared in `optionalDependencies`.
#### Scenario: built dist references no browser globals
- **WHEN** the built `dist/` is grep'd for `window`, `document`, `navigator`, `AudioContext`, or `AudioBuffer`
- **THEN** it MUST NOT statically reference any of them at module scope
#### Scenario: child_process is confined to the runner module
- **WHEN** the source tree is grep'd for `from "node:child_process"` or `require("child_process")`
- **THEN** the only match is inside `src/runner/runner.ts`
**Verified by:** `tests/purity.test.ts::"dist references no browser globals"` AND `tests/purity.test.ts::"child_process is imported only by the runner module"`
---
### Requirement: AudioCodec contract conformance
The package SHALL export `FfmpegNativeCodec` implementing the `AudioCodec` interface from `@suaveplan/audio-codecs`. The codec SHALL expose readonly `name === "ffmpeg-native"`, `extensions` covering `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`, `wma`, and `mimeTypes` covering `audio/wav`, `audio/mpeg`, `audio/ogg`, `audio/opus`, `audio/flac`, `audio/aac`, `audio/mp4`, `audio/webm`, `audio/x-ms-wma`. `decode(bytes)` SHALL return a `DecodeResult` (`{ channelData: Float32Array[]; sampleRate: number; format: string }`). `encode(pcm, opts)` SHALL return a `Uint8Array` whose bytes are a valid file of the requested format. `canDecode(bytes)` SHALL delegate magic-byte detection to `@suaveplan/audio-metadata` and return `true` iff the detected format is in the supported list.
#### Scenario: decode returns a well-formed DecodeResult
- **WHEN** a caller invokes `decode` on a valid 16-bit WAV buffer
- **THEN** the result has `channelData.length === channels`, every channel is a `Float32Array` of length `frames`, `sampleRate` is positive, and `format === "wav"`
#### Scenario: canDecode rejects non-audio bytes
- **WHEN** `canDecode` is called with 64 bytes of random data whose magic matches no supported format
- **THEN** it returns `false` without spawning any subprocess
**Verified by:** `src/codec/codec.test.ts::"decode returns a well-formed DecodeResult for 16-bit WAV"` AND `src/codec/codec.test.ts::"canDecode returns false for unrecognised bytes without spawning"`
---
### Requirement: Binary discovery and resolution order
The package SHALL resolve the ffmpeg binary path on first use in this exact order: (1) `options.binaryProvider?.()` if provided, (2) `options.binaryPath` if provided, (3) the path exposed by dynamically importing `@ffmpeg-installer/ffmpeg`, (4) throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING`. Resolution SHALL be cached on the codec instance after first success and SHALL never happen at module import time.
#### Scenario: binaryProvider overrides all other sources
- **WHEN** a codec is constructed with both `binaryProvider: () => "/usr/local/bin/ffmpeg"` and `binaryPath: "/opt/ffmpeg"` and `@ffmpeg-installer/ffmpeg` installed
- **THEN** the spawned process uses `/usr/local/bin/ffmpeg`
#### Scenario: missing binary throws BINARY_MISSING
- **WHEN** `decode` is called on a codec whose `binaryProvider` returns `""`, no `binaryPath` is set, and `@ffmpeg-installer/ffmpeg` is uninstallable in the test fixture
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"`
**Verified by:** `src/binary/binary.test.ts::"binaryProvider takes precedence over binaryPath and installer"` AND `src/binary/binary.test.ts::"unresolved binary throws AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"`
---
### Requirement: Decode pipeline streams chunk-by-chunk
`decode(bytes)` SHALL feed the input through ffmpeg's `stdin`, drain `stdout` chunk-by-chunk into per-channel `Float32Array` accumulators, and SHALL NOT call `Buffer.concat` on the full output payload. Decode of a payload larger than the host's free heap SHALL complete successfully provided the resulting PCM fits — buffering of the encoded input is forbidden. A non-zero ffmpeg exit code SHALL throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED` carrying the captured stderr tail in `context.stderr`.
#### Scenario: decode does not buffer full output
- **WHEN** the runner module is instrumented to observe `Buffer.concat` calls and a 50 MiB WAV is decoded
- **THEN** zero `Buffer.concat` calls observe the full output payload — output is appended chunk-by-chunk
#### Scenario: ffmpeg failure throws DECODE_FAILED with stderr context
- **WHEN** `decode` is called with bytes that ffmpeg rejects (e.g. a truncated MP3 header)
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED"` with the captured stderr tail in `context.stderr`
**Verified by:** `src/decode/decode.test.ts::"decode appends chunks without concatenating the full output"` AND `src/decode/decode.test.ts::"decode throws AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED on non-zero exit"`
---
### Requirement: Encode pipeline streams chunk-by-chunk
`encode(pcm, opts)` SHALL interleave the per-channel `Float32Array[]` PCM through a fixed-size working buffer (≤ 64 KiB) into ffmpeg's `stdin`, drain `stdout` chunk-by-chunk, and emit a `Uint8Array` whose bytes form a complete file of `opts.format`. Encode SHALL validate `pcm` shape (every channel same length, length > 0) and throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM` for malformed input. A non-zero ffmpeg exit code SHALL throw `AUDIO_CODEC_FFMPEG_NATIVE.ENCODE_FAILED`.
#### Scenario: encode round-trips through decode
- **WHEN** `encode` is called on the `DecodeResult` of a 16-bit WAV and the resulting bytes are decoded again
- **THEN** the re-decoded `channelData` matches the original within ±1e-4 per sample
#### Scenario: ragged PCM throws INVALID_PCM
- **WHEN** `encode` is called with `pcm = [Float32Array(100), Float32Array(99)]`
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM"` before any subprocess is spawned
**Verified by:** `src/encode/encode.test.ts::"encode then decode round-trips PCM within tolerance"` AND `src/encode/encode.test.ts::"encode rejects ragged channel arrays with INVALID_PCM"`
---
### Requirement: Streaming decoder factory
`createDecoder(opts)` SHALL return a `StreamingDecoder` exposing `write(chunk: Uint8Array): Promise<DecodeResult | undefined>`, `flush(): Promise<DecodeResult | undefined>`, and a `readable: ReadableStream<AudioPage>` accessor. Successive `write` calls SHALL forward chunks to ffmpeg's `stdin` and yield `AudioPage`s as `stdout` produces frames. The decoder SHALL expose a `@suaveplan/streams` `TransformStream<Uint8Array, AudioPage>` via a `createDecodeTransform()` helper so callers can compose it with `pipeThrough`.
#### Scenario: streamed decode total equals one-shot decode
- **WHEN** a 1 MiB WAV is split into 64 KiB chunks and fed through `createDecoder` while the same payload is decoded via one-shot `decode`
- **THEN** the concatenated streamed `channelData` is byte-equal to the one-shot `channelData`
#### Scenario: TransformStream composes into a streams pipeline
- **WHEN** `createDecodeTransform("wav")` is piped between a `ReadableStream<Uint8Array>` source and a `WritableStream<AudioPage>` sink
- **THEN** the pipeline drains without buffering the full source and the sink receives one or more non-empty `AudioPage` chunks
**Verified by:** `src/stream/stream-decoder.test.ts::"chunked write yields the same channelData as one-shot decode"` AND `src/stream/stream-decoder.test.ts::"createDecodeTransform composes into a @suaveplan/streams pipeline"`
---
### Requirement: Streaming encoder factory
`createEncoder(opts)` SHALL return a `StreamingEncoder` exposing `write(pcm: Float32Array[]): Promise<Uint8Array | undefined>`, `flush(): Promise<Uint8Array | undefined>`, and a `readable: ReadableStream<Uint8Array>` accessor. The package SHALL also export `createEncodeTransform(opts)` returning a `@suaveplan/streams` `TransformStream<Float32Array[], Uint8Array>`. Encoding SHALL stream output chunk-by-chunk; the encoder SHALL NOT buffer the full encoded output before yielding the first chunk.
#### Scenario: streamed encode yields output before flush
- **WHEN** a 60-second MP3 encode runs via `createEncoder` and the first `write` is followed by 5 seconds of additional `write` calls before `flush`
- **THEN** at least one non-empty `Uint8Array` chunk is yielded from the readable before `flush` resolves
**Verified by:** `src/stream/stream-encoder.test.ts::"streamed encode yields output before flush is called"`
---
### Requirement: AbortSignal propagation kills the subprocess
Every public method (`decode`, `encode`, `createDecoder`, `createEncoder`) SHALL accept an optional `signal: AbortSignal`. A signal already aborted at call time SHALL short-circuit before any subprocess is spawned and throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.ABORTED`. A signal aborted mid-operation SHALL send `SIGTERM` to the child, start a 1-second grace timer, send `SIGKILL` if the child has not exited when the timer fires, release the semaphore slot, and reject with `AUDIO_CODEC_FFMPEG_NATIVE.ABORTED` carrying the abort `reason` in `context.reason`.
#### Scenario: pre-aborted signal short-circuits before spawn
- **WHEN** `decode` is called with an `AbortSignal` whose `aborted === true`
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED"` and no subprocess is spawned
#### Scenario: mid-decode abort escalates SIGTERM to SIGKILL
- **WHEN** a long-running decode is aborted, the child ignores `SIGTERM`, and 1 second elapses
- **THEN** the child receives `SIGKILL`, the semaphore slot is released, and the call rejects with `AUDIO_CODEC_FFMPEG_NATIVE.ABORTED`
**Verified by:** `src/abort/abort.test.ts::"pre-aborted signal short-circuits before spawn"` AND `src/abort/abort.test.ts::"mid-decode abort escalates SIGTERM to SIGKILL after 1s grace"`
---
### Requirement: Concurrency limit via in-package semaphore
The codec SHALL enforce a per-instance concurrency limit (default `4`, override via `options.concurrencyLimit`) using an in-package semaphore — no external dependency. Calls that exceed the limit SHALL queue and acquire a slot in FIFO order; slot release SHALL be guaranteed in a `finally` block so errors and aborts never leak slots. The codec SHALL NOT import `p-limit`, `async-sema`, or any other concurrency library.
#### Scenario: concurrency limit bounds active subprocesses
- **WHEN** 10 `decode` calls are issued in parallel against a codec with `concurrencyLimit: 2`
- **THEN** at any instant the number of live child processes is ≤ 2, and all 10 calls eventually resolve
#### Scenario: errored call releases its slot
- **WHEN** a `decode` call fails with `_DECODE_FAILED` while 3 other calls are queued behind the semaphore
- **THEN** the next queued call begins within one tick of the failure, proving the slot was released
**Verified by:** `src/semaphore/semaphore.test.ts::"concurrency limit caps live subprocess count"` AND `src/semaphore/semaphore.test.ts::"failed call releases its semaphore slot"`
---
### Requirement: Timeout enforcement
Every subprocess call SHALL be bounded by `options.timeoutMs` (default `30000`). When the timer elapses before the child exits, the runner SHALL send `SIGTERM`, start a 1-second grace timer, send `SIGKILL` on grace expiry, and reject with `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"` carrying `timeoutMs` in `context`. Timeouts SHALL release the semaphore slot in the same `finally` path as aborts.
#### Scenario: timeout kills the child and rejects with TIMEOUT
- **WHEN** a codec is constructed with `timeoutMs: 50` and given a synthetic input that causes ffmpeg to hang
- **THEN** the call rejects with `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"` and the child process exits
**Verified by:** `src/runner/runner.test.ts::"timeout kills hung child and rejects with AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"`
---
### Requirement: Every error code is reachable from a call site
The package SHALL export `AudioCodecFfmpegNativeErrorCode`, `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA`, and `registerAudioCodecFfmpegNativeErrorCodes()`, registering the codes `AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING`, `_SPAWN_FAILED`, `_DECODE_FAILED`, `_ENCODE_FAILED`, `_TIMEOUT`, `_ABORTED`, `_UNSUPPORTED_CODEC`, and `_INVALID_PCM` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCodecFfmpegNativeError` extending `SuaveplanError` and carry one of these codes, a `cause`, and a `context`. Every registered code SHALL be reachable from a real call site exercised by tests — no code may be declared and unused.
#### Scenario: every registered code is asserted thrown by tests
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` is asserted thrown by at least one test, including `_SPAWN_FAILED` (via a `binaryProvider` returning a non-executable path) and `_UNSUPPORTED_CODEC` (via `wma` encode)
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CODEC_FFMPEG_NATIVE code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `audio-codec-ffmpeg-native.decode` and `audio-codec-ffmpeg-native.encode` carrying `format`, `sampleRate`, `channels`, and `byteLength` attributes; a counter `audio_codec_ffmpeg_native_invocations_total` with `op` and `format` labels; a histogram `audio_codec_ffmpeg_native_duration_ms`; and an up/down counter `audio_codec_ffmpeg_native_inflight` tracking live subprocess count. Instrumentation SHALL be inert when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: a decode records span, counter, histogram, and inflight gauge
- **WHEN** `decode` runs against a real in-memory `MeterProvider` and `TracerProvider`
- **THEN** one `audio-codec-ffmpeg-native.decode` span is recorded with `format` attribute, the invocations counter increments by 1, the duration histogram records one observation, and the inflight gauge reaches 1 during the call and returns to 0 on completion
#### Scenario: import is side-effect-free
- **WHEN** the package is imported and never used
- **THEN** no metric or span is emitted and no global OTel state is mutated
**Verified by:** `src/otel/otel.test.ts::"decode records span, counter, histogram, and inflight gauge"` AND `tests/purity.test.ts::"importing the package mutates no global OTel state"`
---
### Requirement: Zod boundary validation
The public options objects — `FfmpegNativeCodecOptions` and `EncodeOpts` — SHALL have Zod schemas exported from sibling `*.schemas.ts` files. The codec constructor SHALL validate `FfmpegNativeCodecOptions` against `FfmpegNativeCodecOptionsSchema`, and `encode` / `createEncoder` SHALL validate `EncodeOpts` against `EncodeOptsSchema`. A validation failure SHALL throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM` (for PCM-shape failures) or `AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC` (for an unknown `format`) carrying the Zod issue list in `context.issues`.
#### Scenario: negative concurrencyLimit is rejected at construction
- **WHEN** `new FfmpegNativeCodec({ concurrencyLimit: -1 })` is called
- **THEN** it throws an error whose `cause` is a Zod issue listing the failing path
#### Scenario: unknown format throws UNSUPPORTED_CODEC
- **WHEN** `encode(pcm, { format: "xyz", sampleRate: 44100 })` is called
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"` with the issues in `context.issues`
**Verified by:** `src/codec/codec.test.ts::"constructor rejects invalid options via Zod"` AND `src/encode/encode.test.ts::"unknown format throws AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module — `./codec/codec`, `./binary/binary`, `./runner/runner`, `./semaphore/semaphore`, `./decode/decode`, `./encode/encode`, `./stream/stream-decoder`, `./stream/stream-encoder`, `./abort/abort`, `./otel/otel`, `./register/register`, `./error-codes` — in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`. Feature-module barrels SHALL use explicit named re-exports — no `export *` (rule 10 / §6a).
#### Scenario: each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-codec-ffmpeg-native/register/register`
- **THEN** the import resolves to the register module without pulling the runner, semaphore, or OTel modules
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry and is independently importable"`
---
### Requirement: register helper installs the codec into a CodecRegistry
The package SHALL export `register(registry: CodecRegistry, options?: FfmpegNativeCodecOptions): void`, which constructs a single `FfmpegNativeCodec` instance with the given options and calls `registry.register(codec)`. The helper SHALL be idempotent — calling it twice on the same registry with the same options SHALL leave the registry holding the most-recently-registered instance (per `CodecRegistry.register` replace semantics). The helper SHALL be the canonical one-line install path documented in the README.
#### Scenario: register installs the codec under the ffmpeg-native name
- **WHEN** `register(registry)` is called against a fresh `CodecRegistry`
- **THEN** `registry.get("ffmpeg-native")` returns an `FfmpegNativeCodec` instance, and `registry.detect(wavBytes)` returns `"ffmpeg-native"` when no other codec is registered
**Verified by:** `src/register/register.test.ts::"register installs FfmpegNativeCodec under the ffmpeg-native name"`
---
### Requirement: Declared format support matrix
The codec SHALL support the following operations exactly: decode for `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`, `wma`; encode for `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`; streaming decode for every decodable format; streaming encode for every encodable format. `encode(pcm, { format: "wma" })` SHALL throw `AudioCodecFfmpegNativeError` with code `AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC` regardless of binary capability. When the underlying ffmpeg binary lacks a codec listed as supported (e.g. an `aac` encoder build without `libfdk_aac` or `aac`), the runner SHALL surface a `AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC` error rather than a generic `_ENCODE_FAILED`.
#### Scenario: WMA encode is rejected by contract
- **WHEN** `encode(pcm, { format: "wma", sampleRate: 44100 })` is called
- **THEN** it throws `AudioCodecFfmpegNativeError` whose `code === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"` and no subprocess is spawned
#### Scenario: round-trip across declared encodable formats
- **WHEN** a 1-second sine-wave `Float32Array[]` is encoded then decoded for each of `wav`, `mp3`, `ogg`, `opus`, `flac`, `aac`, `m4a`, `webm`
- **THEN** every round trip succeeds and the decoded PCM length matches the input length within one frame of tolerance
**Verified by:** `src/codec/codec.test.ts::"WMA encode throws AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC without spawning"` AND `tests/integration.test.ts::"round-trip across every declared encodable format"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,296 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-codec-ffmpeg-wasm (audio-codec-ffmpeg-wasm)
**Package**: `@suaveplan/audio-codec-ffmpeg-wasm`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Browser-tier purity
The `@suaveplan/audio-codec-ffmpeg-wasm` package SHALL be browser-tier and MUST NOT import Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`, `require`) at any point — neither statically nor through a dynamic `import()`. Top-level imports SHALL be limited to `@suaveplan/audio-codecs`, `@suaveplan/audio-core`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `@ffmpeg/ffmpeg`, `zod`, `@opentelemetry/api`, and `tslib`. `@ffmpeg/core` SHALL be loaded only via the `@ffmpeg/ffmpeg` runtime, not via a static `import`. `@ffmpeg/core-mt` SHALL be declared in `optionalDependencies` and reached only via dynamic `import()` from inside the COI-gated branch of the core loader. `package.json` SHALL declare `"browser": true` and `"sideEffects": false`.
#### Scenario: built dist references no Node APIs
- **WHEN** the built `dist/` is grep'd for `node:`, `process.`, `Buffer.`, or top-level `require(`
- **THEN** the search returns zero matches in any file under `dist/`
#### Scenario: `@ffmpeg/core-mt` is loaded only inside a dynamic import
- **WHEN** the built `dist/` is searched for `@ffmpeg/core-mt`
- **THEN** every occurrence is inside an `import("@ffmpeg/core-mt")` expression, never inside a static `import … from` statement
**Verified by:** `tests/purity.test.ts::"dist contains no Node APIs"` AND `tests/purity.test.ts::"@ffmpeg/core-mt is reached only via dynamic import"`
---
### Requirement: AudioCodec contract conformance
The package SHALL export a `FfmpegWasmCodec` class implementing the `AudioCodec` interface from `@suaveplan/audio-codecs`. The instance SHALL expose `name === "ffmpeg-wasm"`, an `extensions` array covering `"wav"`, `"mp3"`, `"ogg"`, `"opus"`, `"flac"`, `"aac"`, and `"webm"` (filtered down to only those the loaded core's encoder/decoder probe actually supports), and a matching `mimeTypes` array. The class SHALL implement `canDecode(bytes)`, `decode(bytes)`, `encode(pcm, opts)`, `createDecoder()`, and `createEncoder()` per the `AudioCodec` contract, plus a `dispose(): Promise<void>` that terminates the worker and releases the loaded core.
#### Scenario: instance satisfies the AudioCodec interface
- **WHEN** an `FfmpegWasmCodec` is constructed and inspected
- **THEN** it has the declared `name`, non-empty `extensions` and `mimeTypes` arrays, and the methods `canDecode`, `decode`, `encode`, `createDecoder`, `createEncoder`, `dispose` are all functions
#### Scenario: decode return shape matches DecodeResult
- **WHEN** `decode` succeeds against a valid WAV input
- **THEN** the result is `{ channelData: Float32Array[]; sampleRate: number; format: string }` with `channelData.length === channels` and `format === "wav"`
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"instance satisfies the AudioCodec interface"` AND `src/codec/ffmpeg-wasm-codec.test.ts::"decode returns a well-formed DecodeResult"`
---
### Requirement: Lazy core load on first use
The package SHALL NOT load the `@ffmpeg/core` wasm payload at module import time, at `FfmpegWasmCodec` construction time, or via any module-level side effect. The core SHALL be loaded only on the first call to `decode`, `encode`, `createDecoder`, or `createEncoder`. Subsequent calls SHALL reuse the loaded core. A core-load failure SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED` carrying the resolved core URL and the underlying error as `cause`.
#### Scenario: importing the package loads no wasm
- **WHEN** the package is imported and `new FfmpegWasmCodec()` is constructed
- **THEN** no network request to a `@ffmpeg/core` resource has been issued and no wasm module has been instantiated
#### Scenario: core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED
- **WHEN** `decode` is called with a `corePath` that resolves to a non-existent URL
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED"` with the URL in `context.coreUrl`
**Verified by:** `src/core-loader/core-loader.test.ts::"importing the package does not load wasm"` AND `src/core-loader/core-loader.test.ts::"core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED"`
---
### Requirement: Worker isolation for the wasm core
The package SHALL run the ffmpeg.wasm core inside a dedicated `Worker` so the main thread is never blocked by decode or encode work. Construction of the worker SHALL go through `opts.workerFactory()` when supplied (test seam); otherwise the package uses `@ffmpeg/ffmpeg`'s default worker construction. A worker that fails to spawn SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED` carrying the underlying error as `cause`.
#### Scenario: workerFactory test seam is honoured
- **WHEN** `decode` is called on an `FfmpegWasmCodec` constructed with `workerFactory: () => fakeWorker`
- **THEN** the fake worker receives the decode messages and no real `Worker` constructor is invoked
#### Scenario: worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED
- **WHEN** `opts.workerFactory` returns a worker that immediately emits an `error` event before `load()` resolves
- **THEN** the first method call throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED"`
**Verified by:** `src/worker/worker-host.test.ts::"workerFactory test seam is honoured"` AND `src/worker/worker-host.test.ts::"worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED"`
---
### Requirement: Decode pipeline
`decode(bytes)` SHALL write the input bytes into ffmpeg's MEMFS, run an ffmpeg command that emits `f32le` interleaved samples to a MEMFS output, read that output back, deinterleave into `Float32Array[]` per channel, and return a `DecodeResult` carrying the sniffed `sampleRate` and the input `format` name. Both MEMFS entries SHALL be deleted before returning. A non-zero ffmpeg exit SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` carrying the stderr tail in `context.stderr` and the resolved `format` in `context.format`.
#### Scenario: decode produces normalised float PCM
- **WHEN** `decode` is called with a 16-bit WAV buffer
- **THEN** the result `channelData` is `Float32Array[]` with all samples in `[-1, 1]` and `sampleRate` equals the source rate
#### Scenario: ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED
- **WHEN** `decode` is called with bytes that ffmpeg rejects (e.g. a truncated MP3 header)
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED"` with the stderr tail in `context.stderr`
**Verified by:** `src/decode/decode-pipeline.test.ts::"decode produces normalised float PCM"` AND `src/decode/decode-pipeline.test.ts::"ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED"`
---
### Requirement: Encode pipeline
`encode(pcm, opts)` SHALL interleave `Float32Array[]` channels into a single buffer, write it to MEMFS as `f32le`, run the format-specific ffmpeg command (one per `wav`/`mp3`/`ogg`/`opus`/`flac`/`aac`/`webm`) honouring `opts.bitrate` / `opts.quality` when provided, read the encoded output from MEMFS, delete both MEMFS entries, and return the encoded `Uint8Array`. A `format` outside the supported set SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` *before* the worker is invoked. A non-zero ffmpeg exit SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED` carrying the stderr tail in `context.stderr`.
#### Scenario: encode produces a valid file for the requested format
- **WHEN** `encode` is called with a single-channel sine wave and `format: "mp3"`
- **THEN** the returned bytes start with an MP3 frame sync (`0xFFFB` or `0xFFFA`) and decoding them back yields PCM whose length matches the input within ±1 frame
#### Scenario: unsupported format throws AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is touched
- **WHEN** `encode` is called with `format: "alac"`
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"` and no message is posted to the worker
**Verified by:** `src/encode/encode-pipeline.test.ts::"encode produces a valid file for the requested format"` AND `src/encode/encode-pipeline.test.ts::"unsupported format throws AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is touched"`
---
### Requirement: Buffered streaming emulation
`createDecoder()` and `createEncoder()` SHALL return objects implementing the `StreamingDecoder` and `StreamingEncoder` interfaces from `@suaveplan/audio-codecs`. The implementation SHALL be buffered emulation: `write(chunk)` SHALL append the chunk to an internal `Uint8Array` and return `null` without invoking the worker; `flush()` SHALL submit the accumulated bytes through the same decode or encode pipeline as the non-streaming method and return the single resulting `DecodeResult` or encoded `Uint8Array`. Subsequent `flush()` calls SHALL be no-ops. Progress events SHALL fire only during the `flush()` call.
#### Scenario: write does not invoke the worker
- **WHEN** a caller invokes `createDecoder()` and calls `write(chunk)` three times
- **THEN** no message has been posted to the worker and every call returned `null`
#### Scenario: flush yields the same bytes as a one-shot decode
- **WHEN** a caller streams a WAV file through `createDecoder()` in three chunks and calls `flush()`
- **THEN** the resulting `DecodeResult.channelData` is byte-equal to `decode(fullBuffer).channelData`
**Verified by:** `src/streaming/streaming-decoder.test.ts::"write does not invoke the worker"` AND `src/streaming/streaming-decoder.test.ts::"flush yields the same bytes as a one-shot decode"`
---
### Requirement: AbortSignal propagation terminates the worker
`decode`, `encode`, `StreamingDecoder.flush`, and `StreamingEncoder.flush` SHALL accept an `AbortSignal` via their options object. A signal that is already aborted SHALL short-circuit the call with `AudioCodecFfmpegWasmError` code `AUDIO_CODEC_FFMPEG_WASM.ABORTED` before any worker message is posted. A signal aborted mid-call SHALL trigger `worker.terminate()`, reject the pending call with the same code, release any worker reference, and require the next call to spawn a fresh worker.
#### Scenario: pre-aborted signal short-circuits before worker touch
- **WHEN** `decode` is called with an already-aborted signal
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.ABORTED"` and no message has been posted to the worker
#### Scenario: mid-call abort terminates the worker
- **WHEN** a signal is aborted after the worker received the decode message but before it responded
- **THEN** `worker.terminate()` is invoked, the decode promise rejects with `AUDIO_CODEC_FFMPEG_WASM.ABORTED`, and the next `decode` call constructs a new worker
**Verified by:** `src/worker/worker-host.test.ts::"pre-aborted signal short-circuits before worker touch"` AND `src/worker/worker-host.test.ts::"mid-call abort terminates the worker"`
---
### Requirement: Timeout watchdog
`decode` and `encode` SHALL apply a timeout to the underlying worker call. The default timeout SHALL be 60 000 ms for decode and 120 000 ms for encode; both SHALL be overridable via the call options. A timeout SHALL terminate the worker and throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT` carrying the elapsed milliseconds in `context.elapsedMs` and the configured limit in `context.timeoutMs`. The watchdog SHALL be driven by `opts.clock` when supplied so tests are deterministic.
#### Scenario: timeout throws AUDIO_CODEC_FFMPEG_WASM.TIMEOUT
- **WHEN** `decode` is called with `timeoutMs: 10` against a worker that never responds, using an injectable `Clock`
- **THEN** the call throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.TIMEOUT"` with `elapsedMs >= 10` and `timeoutMs === 10`
**Verified by:** `src/worker/worker-host.test.ts::"timeout throws AUDIO_CODEC_FFMPEG_WASM.TIMEOUT"`
---
### Requirement: Memory-limit detection
When ffmpeg.wasm exhausts its 4 GB linear-memory cap — surfaced as an `Aborted()` JS exception or an `OutOfMemory`/`Cannot enlarge memory` stderr line — the worker host SHALL re-emit the failure as `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED` carrying the requested allocation size (when parseable from stderr) in `context.requestedBytes`. The codec MUST NOT silently swallow OOM into the generic `DECODE_FAILED` / `ENCODE_FAILED` codes.
#### Scenario: wasm OOM maps to AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED
- **WHEN** the test worker emits a stderr line `"Cannot enlarge memory arrays."`
- **THEN** the pending call throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED"`
**Verified by:** `src/worker/worker-host.test.ts::"wasm OOM maps to AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED"`
---
### Requirement: Cross-origin-isolation gate for the multi-thread variant
When `FfmpegWasmCodecOptions.enableMultithread` is `true`, the core loader SHALL inspect `globalThis.crossOriginIsolated`. If it is not exactly `true`, the loader SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` *before* attempting to load `@ffmpeg/core-mt`. When COI is `true` and the dynamic `import("@ffmpeg/core-mt")` rejects (optional dep not installed), the loader SHALL log a warning via `opts.logger` and fall back to single-thread `@ffmpeg/core` rather than throwing. When `enableMultithread` is unset or `false`, the single-thread core SHALL be loaded unconditionally and the COI flag SHALL NOT be consulted.
#### Scenario: enableMultithread without COI throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED
- **WHEN** `decode` is first called on an `FfmpegWasmCodec` constructed with `enableMultithread: true` in an environment where `globalThis.crossOriginIsolated === false`
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED"` and `@ffmpeg/core-mt` has not been dynamic-imported
#### Scenario: missing @ffmpeg/core-mt falls back silently
- **WHEN** COI is `true`, `enableMultithread` is `true`, and `import("@ffmpeg/core-mt")` rejects with a module-not-found error
- **THEN** the single-thread `@ffmpeg/core` loads successfully, a warning is recorded via `opts.logger`, and the call completes
**Verified by:** `src/core-loader/core-loader.test.ts::"enableMultithread without COI throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED"` AND `src/core-loader/core-loader.test.ts::"missing @ffmpeg/core-mt falls back silently"`
---
### Requirement: Progress callback wiring
When `FfmpegWasmCodecOptions.progressCallback` is supplied, the codec SHALL invoke it with a number in `[0, 1]` for every `progress` event emitted by `@ffmpeg/ffmpeg` during a decode or encode call. The callback SHALL be invoked at least once per call that runs through the worker (zero and one are both acceptable bookends). Callback exceptions SHALL be caught and logged via `opts.logger` without aborting the in-flight call.
#### Scenario: progress callback receives values in [0, 1]
- **WHEN** `decode` runs against a multi-second WAV with `progressCallback` set
- **THEN** the callback is invoked at least once with a number in `[0, 1]`, and the final invocation is `1` (or `decode` completes without one — both are acceptable)
#### Scenario: progress callback exception does not abort decode
- **WHEN** `progressCallback` throws on its first invocation
- **THEN** the error is logged via `opts.logger` and `decode` still resolves with a valid `DecodeResult`
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"progress callback receives values in [0, 1]"` AND `src/codec/ffmpeg-wasm-codec.test.ts::"progress callback exception does not abort decode"`
---
### Requirement: Structured error codes — every code reachable
The package SHALL export `AudioCodecFfmpegWasmError`, `AudioCodecFfmpegWasmErrorCode`, `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA`, and `registerAudioCodecFfmpegWasmErrorCodes()`, registering the codes `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED`, `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT`, `AUDIO_CODEC_FFMPEG_WASM.ABORTED`, `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC`, `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED`, and `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCodecFfmpegWasmError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every code SHALL be reachable from a real call site exercised by tests.
#### Scenario: every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** for each code in `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA`, at least one test asserts that code is thrown
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CODEC_FFMPEG_WASM code is reachable from a call site"`
---
### Requirement: Zod boundary validation
`FfmpegWasmCodecOptionsSchema` SHALL be exported from `src/codec/ffmpeg-wasm-codec.schemas.ts` and validate `FfmpegWasmCodecOptions` at the `FfmpegWasmCodec` constructor and at the `register(registry, options?)` entry point. A validation failure SHALL throw `AudioCodecFfmpegWasmError` with code `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` carrying the Zod issue list in `context.issues`. Options SHALL reject `coreUrl` and `corePath` being set simultaneously.
#### Scenario: invalid options are rejected at the constructor
- **WHEN** `new FfmpegWasmCodec({ coreUrl: "https://a/", corePath: "/b/" })`
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"` with Zod issues in `context.issues`
#### Scenario: register validates its options argument
- **WHEN** `register(registry, { enableMultithread: "yes" as unknown as boolean })` is called
- **THEN** it throws `AudioCodecFfmpegWasmError` whose `code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"` with Zod issues in `context.issues`
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"invalid options are rejected at the constructor"` AND `src/register/register.test.ts::"register validates its options argument"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `audio-codec-ffmpeg-wasm.decode` and `audio-codec-ffmpeg-wasm.encode`; counters `audio_codec_ffmpeg_wasm_decode_total` and `audio_codec_ffmpeg_wasm_encode_total`; histograms `audio_codec_ffmpeg_wasm_decode_duration_ms` and `audio_codec_ffmpeg_wasm_encode_duration_ms`. Spans SHALL carry the resolved `format` and `multithread` flag as attributes. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: a decode records a span, counter, and histogram
- **WHEN** `decode` runs against a real in-memory `MeterProvider` and `TracerProvider`
- **THEN** `audio-codec-ffmpeg-wasm.decode` records one span carrying the `format` and `multithread` attributes, `audio_codec_ffmpeg_wasm_decode_total` increments by 1, and `audio_codec_ffmpeg_wasm_decode_duration_ms` records one observation
**Verified by:** `src/codec/ffmpeg-wasm-codec.test.ts::"decode records a span, counter, and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: `register(registry)` install helper
The package SHALL export a `register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec` function. It SHALL validate `options` via `FfmpegWasmCodecOptionsSchema`, construct a `FfmpegWasmCodec` with those options, call `registry.register(codec)`, and return the constructed instance so callers can `dispose()` it later. A second call with the same registry SHALL replace the prior registration (matching `CodecRegistry.register`'s replace-on-duplicate-name behaviour from `audio-codecs`).
#### Scenario: register installs the codec and returns the instance
- **WHEN** `register(registry)` is called against a fresh `CodecRegistry`
- **THEN** the returned `FfmpegWasmCodec` is identity-equal to `registry.get("ffmpeg-wasm")` and `registry.detect(wavBytes)` returns `"ffmpeg-wasm"`
**Verified by:** `src/register/register.test.ts::"register installs the codec and returns the instance"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./codec/ffmpeg-wasm-codec`, `./worker/worker-host`, `./core-loader/core-loader`, `./decode/decode-pipeline`, `./encode/encode-pipeline`, `./streaming/streaming-decoder`, `./streaming/streaming-encoder`, `./register/register`, `./error-codes`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`. Test files SHALL import runner primitives only from `@suaveplan/testing/runner` (rule 11) — direct imports from `vitest` or `bun:test` are forbidden.
#### Scenario: each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-codec-ffmpeg-wasm/register/register`
- **THEN** the import resolves without pulling the decode or encode pipelines, and no wasm has loaded
#### Scenario: no test file imports vitest or bun:test directly
- **WHEN** `bun scripts/lint/check-test-runner-imports.ts` is run against this package
- **THEN** the gate passes with zero violations
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"` AND `tests/exports.test.ts::"no test file imports vitest or bun:test directly"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,284 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-controllers (audio-controllers)
**Package**: `@suaveplan/audio-controllers`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Browser-tier purity with injectable MIDI access
The `@suaveplan/audio-controllers` package MAY use the Web MIDI API (`navigator.requestMIDIAccess`, `MIDIAccess`, `MIDIInput`, `MIDIOutput`, `MIDIMessageEvent`, `MIDIConnectionEvent`) because it is a browser-tier package, but it SHALL import zero Node-only APIs (`node:fs`, `node:path`, `node:crypto`, `process`, `Buffer`). All MIDI access SHALL be reached only through an injected `MidiAccessAdapter` so the package never calls `navigator.requestMIDIAccess` directly — tests inject a real fake-device fixture rather than mocking the platform. The package's runtime `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no Node globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `process`, or `Buffer`
#### Scenario: MIDI access flows through the injected adapter
- **WHEN** a `MidiControllerPlugin` subclass is constructed with a `MidiAccessAdapter` fixture and `requestAccess()` is called
- **THEN** the package obtains its `MIDIAccess` from the adapter and never references `navigator` directly, so the fake-device fixture drives the test with no mocks
**Verified by:** `tests/purity.test.ts::"dist references no Node globals"` AND `src/base/midi-controller-plugin.test.ts::"requestAccess resolves MIDIAccess through the injected adapter"`
---
### Requirement: MidiControllerPlugin base class
The package SHALL export an abstract `MidiControllerPlugin` class exposing readonly `metadata: ControllerMetadata`, a readonly `sequencer: Sequencer`, the MIDI lifecycle methods `requestAccess(sysex?)` and `onStateChange(event)`, the feedback method `flushLEDs(updates)`, the mapping method `defineMappings(mappings)`, the learn-mode methods `enterLearnMode()` / `exitLearnMode()`, the typed event methods `on` / `off`, and the lifecycle methods `initialize()` / `destroy()`. `requestAccess` SHALL obtain a `MIDIAccess` via the injected `MidiAccessAdapter`, optionally requesting SysEx. `destroy()` SHALL release every held MIDI port, unsubscribe every listener, and stop the sequencer, and SHALL be idempotent.
#### Scenario: initialize wires inputs and exposes a sequencer
- **WHEN** a caller constructs a concrete `MidiControllerPlugin` subclass and awaits `initialize()`
- **THEN** `requestAccess` has resolved through the adapter, `plugin.sequencer` is a usable `Sequencer`, and the plugin is subscribed to its MIDI inputs
#### Scenario: destroy releases ports and is idempotent
- **WHEN** a caller awaits `destroy()` twice
- **THEN** the first call releases every MIDI port, unsubscribes listeners, and stops the sequencer; the second call is a no-op and does not throw
**Verified by:** `src/base/midi-controller-plugin.test.ts::"initialize wires MIDI inputs and exposes a sequencer"` AND `src/base/midi-controller-plugin.test.ts::"destroy releases ports and is idempotent"`
---
### Requirement: MIDI input and output mapping
The package SHALL map raw MIDI input through a declarative control-change DSL: `defineMappings(mappings: CCMapping[])` registers `CCMapping` records that bind a `(channel, controller)` pair to a named target parameter with an optional value transform and `min`/`max` range. Incoming MIDI messages whose status and data bytes match a registered mapping SHALL emit a typed `control` event carrying the mapped target name and the transformed value. Output mapping SHALL be the inverse: `flushLEDs(updates: LEDUpdate[])` batches `LEDUpdate` records and writes the corresponding MIDI output bytes to the device's `MIDIOutput` port in a single flush. Unmapped MIDI input SHALL emit a `raw` event and SHALL NOT throw.
#### Scenario: a mapped CC message emits a transformed control event
- **WHEN** a `CCMapping` binds `(channel 0, controller 7)` to `"volume"` with range `0..1` and a matching MIDI message arrives via the fake-device fixture
- **THEN** a `control` event fires once with `{ target: "volume", value }` where `value` is the controller byte scaled into `0..1`
#### Scenario: flushLEDs batches output bytes in one write
- **WHEN** a caller passes three `LEDUpdate` records to `flushLEDs`
- **THEN** the injected `MIDIOutput` fixture receives the three corresponding byte sequences in a single flush, in order
**Verified by:** `src/base/midi-controller-plugin.test.ts::"a mapped CC message emits a transformed control event"` AND `src/base/midi-controller-plugin.test.ts::"flushLEDs writes batched output bytes through the MIDIOutput fixture"`
---
### Requirement: Controller connection-state FSM
The package SHALL model each controller's connection lifecycle as a `@suaveplan/fsm` state machine with the states `disconnected`, `connecting`, `connected`, and `error`. `onStateChange(event: MIDIConnectionEvent)` SHALL drive transitions: a `connected` port event moves `connecting`/`disconnected` → `connected`; a `disconnected` port event moves `connected` → `disconnected`; an access or port failure moves any state → `error`. On reconnect the machine SHALL re-apply the last known mappings and LED state (graceful state recovery). Every transition SHALL emit a typed `state` event carrying `{ from, to }`. An illegal transition request SHALL throw `AudioControllersError` with code `AUDIO_CONTROLLERS.INVALID_STATE`.
#### Scenario: hot-unplug then re-plug recovers mappings
- **WHEN** the fake-device fixture emits a `disconnected` then a `connected` `MIDIConnectionEvent` for the same port
- **THEN** the FSM transitions `connected → disconnected → connecting → connected`, two `state` events fire, and the previously registered `CCMapping` set and LED state are re-applied
#### Scenario: illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE
- **WHEN** a caller forces a transition the FSM does not permit from the current state
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.INVALID_STATE"` with `from` and `to` in `context`
**Verified by:** `src/base/connection-fsm.test.ts::"hot-unplug then re-plug recovers mappings and LED state"` AND `src/base/connection-fsm.test.ts::"illegal transition throws AUDIO_CONTROLLERS.INVALID_STATE"`
---
### Requirement: Transport-synced Sequencer
The package SHALL export a `Sequencer` class constructed from a `@suaveplan/audio-transport` `Transport`. It SHALL expose `setTempo(bpm)`, `setSteps(steps: boolean[])`, `setStep(index, active)`, `start()`, `stop()`, `reset()`, and `onStep(index, callback)`. The sequencer SHALL schedule every step on the `Transport` using sample-accurate transport times and SHALL NOT use `setInterval` or `setTimeout` for step timing. `start()` SHALL register a repeating transport callback; on each scheduled tick the active step's `onStep` callbacks SHALL fire with the transport-provided time. `reset()` SHALL return the step cursor to index 0 without stopping the transport.
#### Scenario: active steps fire callbacks at transport ticks
- **WHEN** a caller sets a 4-step pattern with steps 0 and 2 active, registers `onStep` callbacks, and advances the `Transport`
- **THEN** only the callbacks for steps 0 and 2 fire, each with the transport-supplied sample-accurate time, and no `setInterval` is used
#### Scenario: sequencer schedules through the transport only
- **WHEN** the sequencer is started and the test inspects timing sources
- **THEN** every step is scheduled via `Transport` callbacks and the package issues no `setInterval`/`setTimeout`/`Date.now` calls for step timing
**Verified by:** `src/sequencer/sequencer.test.ts::"active steps fire onStep callbacks at transport ticks"` AND `src/sequencer/sequencer.test.ts::"sequencer schedules steps through the transport without setInterval"`
---
### Requirement: Transport-synced Arpeggiator
The package SHALL export an `Arpeggiator` class constructed from a `Transport`. It SHALL expose `setPattern(notes: number[], mode: ArpMode)`, `setRate(interval)`, `start()`, and `stop()`. The arpeggiator SHALL be a `Transport` event subscriber: `start()` SHALL register a repeating transport callback at the configured rate, and each invocation SHALL emit the next note in the pattern (ordered per `ArpMode` — `up`, `down`, `updown`, `random`) with the transport-supplied time. Because scheduling is transport-driven, an arpeggio SHALL be renderable offline by advancing the transport without a real-time clock.
#### Scenario: arpeggio walks the pattern on transport ticks
- **WHEN** a caller sets pattern `[60, 64, 67]` in `up` mode at a `16n` rate, starts the arpeggiator, and advances the `Transport` three steps
- **THEN** notes `60`, `64`, `67` are emitted in order, each with the transport-supplied time
#### Scenario: arpeggio renders offline
- **WHEN** the transport is advanced offline (no wall-clock) over a fixed range
- **THEN** the arpeggiator emits the same note sequence it would emit in real time, proving it depends only on transport ticks
**Verified by:** `src/arpeggiator/arpeggiator.test.ts::"arpeggio walks the pattern in up mode on transport ticks"` AND `src/arpeggiator/arpeggiator.test.ts::"arpeggio produces an identical sequence under offline transport advance"`
---
### Requirement: MIDI clock timestamped against the transport tick timeline
Incoming MIDI messages SHALL be timestamped against the `Transport` tick timeline, not `Date.now()` or `setInterval`. For each `MIDIMessageEvent` the package SHALL compute the message's position relative to `transport.toSeconds(transport.ticks)`, derive the input latency, and schedule the resulting note or control action with `transport.scheduleOnce` at the transport-relative time. This ensures MIDI events are sample-accurate relative to the transport and not subject to JavaScript event-loop jitter.
#### Scenario: an incoming note is scheduled on the transport timeline
- **WHEN** the fake-device fixture emits a note-on `MIDIMessageEvent` with a known `timeStamp`
- **THEN** the package schedules the note via `transport.scheduleOnce` at a transport-relative time derived from `transport.ticks`, and no `Date.now()` or `setInterval` call is involved in the timestamp computation
**Verified by:** `src/base/midi-clock.test.ts::"incoming MIDI message is scheduled against the transport tick timeline"`
---
### Requirement: Per-device controller plugins
The package SHALL ship three concrete `MidiControllerPlugin` subclasses, each with a device-specific `ControllerMetadata` (name, manufacturer, port-name match patterns) and preset `CCMapping` set:
- `KontrolS88Plugin` — exposes `setScale(mode)`, `setArpMode(mode)`, `setPitchBend(value)`, `setModWheel(value)`, and `sendLED(keyIndex, color)`.
- `MaschinePlugin` — exposes `setPadVelocity(padIndex, velocity)`, `setStep(padIndex, stepIndex, active)`, and `loadSample(slotIndex, sampleId)`.
- `TraktorS4Plugin` — exposes `loadDeck(deckIndex, track)`, `setCrossfader(value)`, and `setChannelVolume(channel, value)`.
Each subclass SHALL drive its device through the inherited `flushLEDs` / output path and SHALL validate device-specific arguments (key index, pad index, deck index, channel) against the device's known range, throwing `AudioControllersError` with code `AUDIO_CONTROLLERS.DEVICE_ERROR` on an out-of-range argument.
#### Scenario: KontrolS88Plugin sends a key LED through the output path
- **WHEN** a caller calls `sendLED(keyIndex, color)` on a connected `KontrolS88Plugin`
- **THEN** the injected `MIDIOutput` fixture receives the device-specific LED byte sequence for that key and colour
#### Scenario: out-of-range device argument throws AUDIO_CONTROLLERS.DEVICE_ERROR
- **WHEN** a caller calls `MaschinePlugin.setPadVelocity` with a pad index beyond the device's 16-pad range
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.DEVICE_ERROR"` with the offending index in `context`
**Verified by:** `src/plugins/kontrol-s88/kontrol-s88.test.ts::"sendLED writes the device LED byte sequence through the output fixture"` AND `src/plugins/maschine/maschine.test.ts::"setPadVelocity rejects an out-of-range pad index with AUDIO_CONTROLLERS.DEVICE_ERROR"`
---
### Requirement: Learn mode for CC mapping capture
The package SHALL support a learn mode on `MidiControllerPlugin`: `enterLearnMode()` SHALL put the plugin into a state where the next unmapped MIDI control message is captured as a candidate `CCMapping` and emitted via a typed `learn` event carrying the captured `(channel, controller)` pair; `exitLearnMode()` SHALL leave learn mode. While in learn mode the plugin SHALL NOT emit `control` events for captured messages. Capturing a control message SHALL automatically exit learn mode unless the plugin is configured for continuous capture.
#### Scenario: learn mode captures the next control message
- **WHEN** a caller calls `enterLearnMode()` and the fake-device fixture sends one CC message
- **THEN** a `learn` event fires with the captured `(channel, controller)` pair, no `control` event fires for that message, and the plugin has exited learn mode
**Verified by:** `src/learn/learn-mode.test.ts::"learn mode captures the next control message and exits"`
---
### Requirement: Zod boundary validation
Every public options object — `ControllerMetadata`, `CCMapping`, `LEDUpdate`, `SequencerOptions`, `ArpeggiatorOptions`, and each device plugin's constructor options — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioControllersError` with code `AUDIO_CONTROLLERS.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: invalid CC mapping is rejected at the boundary
- **WHEN** `defineMappings` is called with a `CCMapping` whose `controller` byte is `200` (outside `0..127`)
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.INVALID_OPTIONS"` with the Zod issues in `context`
**Verified by:** `src/base/midi-controller-plugin.test.ts::"defineMappings rejects an invalid CCMapping with AUDIO_CONTROLLERS.INVALID_OPTIONS"`
---
### Requirement: Structured error codes
The package SHALL declare its codes via `defineCodes("AUDIO_CONTROLLERS", [...])` producing the dotted codes `AUDIO_CONTROLLERS.ACCESS_DENIED`, `AUDIO_CONTROLLERS.INVALID_OPTIONS`, `AUDIO_CONTROLLERS.INVALID_STATE`, `AUDIO_CONTROLLERS.DEVICE_ERROR`, `AUDIO_CONTROLLERS.NOT_CONNECTED`, and `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER`. The package SHALL export `AudioControllersErrorCode`, `AUDIO_CONTROLLERS_ERROR_METADATA`, `registerAudioControllersErrorCodes()`, and an `AudioControllersError` class extending `AudioControllersError` from `@suaveplan/audio-core`. Every error thrown by the package SHALL be an `AudioControllersError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: denied MIDI access throws AUDIO_CONTROLLERS.ACCESS_DENIED
- **WHEN** the injected `MidiAccessAdapter` fixture rejects the access request
- **THEN** `requestAccess()` throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.ACCESS_DENIED"` with the underlying rejection as `cause`
#### Scenario: every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_CONTROLLERS_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/base/midi-controller-plugin.test.ts::"requestAccess throws AUDIO_CONTROLLERS.ACCESS_DENIED when the adapter rejects"` AND `src/error-codes.test.ts::"every AUDIO_CONTROLLER code is reachable from a call site"`
---
### Requirement: AbortSignal propagation
`MidiControllerPlugin.requestAccess()` and `MidiControllerPlugin.initialize()`, plus `MaschinePlugin.loadSample()`, SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to reject with `AudioControllersError` code `AUDIO_CONTROLLERS.ACCESS_DENIED` (for access/init) or `AUDIO_CONTROLLERS.DEVICE_ERROR` (for `loadSample`) before any MIDI or device work; a signal aborted mid-operation SHALL terminate the operation at the next await boundary with the same error.
#### Scenario: pre-aborted signal short-circuits initialize
- **WHEN** `initialize()` is called with an already-aborted signal
- **THEN** it rejects with `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.ACCESS_DENIED"` before any MIDI access is requested
#### Scenario: mid-operation abort terminates loadSample
- **WHEN** a signal is aborted while `MaschinePlugin.loadSample()` is awaiting
- **THEN** the operation rejects with `AUDIO_CONTROLLERS.DEVICE_ERROR` at the next await boundary and loads nothing further
**Verified by:** `src/base/midi-controller-plugin.test.ts::"initialize rejects AUDIO_CONTROLLERS.ACCESS_DENIED for a pre-aborted signal"` AND `src/plugins/maschine/maschine.test.ts::"loadSample honours mid-operation abort"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: spans `controller.midi_message`, `controller.sequencer_step`, and `controller.connect`; counters `audio_controller_midi_messages_total` and `audio_controller_sequencer_steps_total`; histograms `audio_controller_midi_latency_ms` and `audio_controller_sequencer_jitter_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: a handled MIDI message records a span and counter
- **WHEN** a MIDI message is handled with an exporter registered
- **THEN** a `controller.midi_message` span is recorded and `audio_controller_midi_messages_total` increments by 1
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/base/midi-clock.test.ts::"handling a MIDI message records a span and counter when an exporter is registered"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./base/midi-controller-plugin`, `./sequencer/sequencer`, `./arpeggiator/arpeggiator`, `./learn/learn-mode`, `./plugins/kontrol-s88`, `./plugins/maschine`, `./plugins/traktor-s4`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-controllers/plugins/kontrol-s88`
- **THEN** the import resolves to the Kontrol S88 plugin module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
---
### Requirement: Bindable parameter control over AudioFxPlugin
The package SHALL expose a `bindParameter(plugin: AudioFxPlugin, paramId: string, source: MidiSource, opts?: { smoothingMs?: number }): UnsubscribeFn` helper from `@suaveplan/audio-controllers/binding` that wires a `MidiSource` (a typed value emitted by a controller — CC, note velocity, pitch-bend, aftertouch, fader, encoder) to a plugin parameter on any `AudioFxPlugin` from `@suaveplan/audio-plugins-fx`. The helper SHALL:
- look up the parameter via `plugin.parameterIndex.get(paramId)` (O(1)) and throw `AudioControllersError` with code `AUDIO_CONTROLLERS.UNKNOWN_PARAMETER` if absent,
- read the parameter's declared `min`, `max`, and optional `scale` and map the source's normalised value into that range using the declared scale (linear / log / exponential),
- call `plugin.setParameter(paramId, mappedValue)` for immediate parameters and `plugin.setParameterCurve(paramId, { type: 'linear', value: mappedValue, duration: opts.smoothingMs / 1000 })` when an optional `smoothingMs` is provided (so a jittery hardware knob produces a smooth audio-rate ramp),
- return an unsubscribe function that removes the binding without disturbing the controller or other bindings,
- bind against `plugin.parameterIndex` references so subsequent parameter metadata changes (where supported by the plugin) are honoured without rebinding.
The package SHALL also expose a `bindPluginPreset(plugin: AudioFxPlugin, source: MidiSource, presets: ReadonlyArray<PresetSnapshot>): UnsubscribeFn` helper that, when triggered, applies the preset whose index corresponds to the source value (typical use: MIDI program-change events). Preset validation failures SHALL propagate the underlying `AudioControllersError` from `plugin.applyPreset` unchanged.
#### Scenario: bindParameter routes a CC into the plugin's parameter
- **WHEN** a caller calls `bindParameter(eq, 'lowShelfGain', { type: 'cc', channel: 1, number: 7 })` and an inbound MIDI CC `1/7` value `64/127` arrives
- **THEN** `eq.setParameter('lowShelfGain', mid)` is called where `mid` equals the linear mapping of `64/127` into the parameter's declared `[min, max]` range within float tolerance
#### Scenario: bindParameter rejects an unknown parameter id
- **WHEN** `bindParameter(eq, 'nonexistent', source)` is called
- **THEN** it throws `AudioControllersError` whose `code === "AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"` with the offending id in `context`
#### Scenario: smoothing option produces a parameter curve
- **WHEN** `bindParameter(eq, 'lowShelfGain', source, { smoothingMs: 20 })` is called and a CC value arrives
- **THEN** the binding calls `plugin.setParameterCurve('lowShelfGain', { type: 'linear', value: mappedValue, duration: 0.02 })` instead of `setParameter`
#### Scenario: unbind removes the binding cleanly
- **WHEN** a caller calls the unsubscribe function returned by `bindParameter`
- **THEN** subsequent inbound events for that source no longer call `setParameter` on the plugin, AND other bindings on the same source continue to fire
**Verified by:** `src/binding/bind-parameter.test.ts::"routes a CC into the plugin parameter with linear scale mapping"` AND `src/binding/bind-parameter.test.ts::"rejects unknown parameter id with AUDIO_CONTROLLERS.UNKNOWN_PARAMETER"` AND `src/binding/bind-parameter.test.ts::"smoothing option produces a parameter curve"` AND `src/binding/bind-parameter.test.ts::"unbind stops routing without disturbing other bindings"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,382 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-core (audio-core)
**Package**: `@suaveplan/audio-core`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The `@suaveplan/audio-core` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All environment-specific behaviour SHALL be reached only through injected adapters. The package's `dependencies` SHALL be limited to `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/streams`, `@suaveplan/serialize`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency.
#### Scenario: Built dist references no platform globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace and zod"`
---
### Requirement: AudioDocument paged container
The package SHALL export an `AudioDocument` class constructed from `AudioDocumentOptions`. It SHALL expose readonly `id`, `pages`, `sampleRate`, `channels`, and `sourceLength`, plus virtual `length` and `duration` getters whose cached values are invalidated by `version` bumps. PCM SHALL be stored as paged `AudioPage` records (`{ channelData: Float32Array[]; startSample: number }`); a `null` slot in the page array indicates an evicted page that can be reloaded. Construction with no `id` SHALL derive a stable id via the injected `IdGenerator`.
#### Scenario: Virtual length tracks edits without materialisation
- **WHEN** a caller appends a `trim` edit that halves the range
- **THEN** `doc.length` reflects the trimmed sample count, `doc.version` has incremented, and no `AudioPage` has been allocated or copied
#### Scenario: Evicted page is represented as null
- **WHEN** a page is evicted from `doc.pages`
- **THEN** the slot holds `null` and a subsequent read for that range triggers a reload rather than throwing
**Verified by:** `src/document/document.test.ts::"virtual length reflects edits with O(1) cost"` AND `src/page/page.test.ts::"evicted page slot is null and reloadable"`
---
### Requirement: Non-destructive edit history
`AudioDocument.run(...edits)` SHALL append `AudioEdit` records, increment `version`, and emit an `edit` event. `undo(n?)` SHALL pop the last `n` edits (default 1), emit an `undo` event, and return the removed edit(s) or `null` when the history is empty. `clone()` SHALL produce a document with an independent `pages` array and `edits` array such that mutating one does not affect the other. Editing SHALL be O(1) in the number of samples — materialisation is deferred to `read()` / `stream()`.
#### Scenario: Run then undo restores edit state
- **WHEN** a caller runs two edits then calls `undo(2)`
- **THEN** `doc.edits` is empty, `doc.version` has incremented twice more, and `undo` returned both removed edits
#### Scenario: Clone is structurally independent
- **WHEN** a caller clones a document and runs an edit on the clone
- **THEN** the original's `edits` and `version` are unchanged
**Verified by:** `src/document/document.test.ts::"run appends and undo removes edits with events"` AND `src/document/document.test.ts::"clone produces an independent document"`
---
### Requirement: Document serialization round-trip
`AudioDocument.toJSON()` SHALL return an `AudioDocumentJSON` value that, when passed through `@suaveplan/serialize`, round-trips exactly — including `Float32Array` page data, the `edits` array, `version`, `source`, and `metadata`. A document reconstructed from the deserialized JSON SHALL produce a `toJSON()` value deep-equal to the original's.
#### Scenario: toJSON survives serialize/deserialize
- **WHEN** a caller serializes `doc.toJSON()` and deserializes it back into an `AudioDocument`
- **THEN** the reconstructed document's `toJSON()` is deep-equal to the original's
**Verified by:** `src/document/document.test.ts::"toJSON round-trips through @suaveplan/serialize"` AND `tests/property.test.ts::"clone toJSON equals original toJSON"`
---
### Requirement: Typed document event surface
`AudioDocument` SHALL expose `on` / `off` methods typed against `AudioDocumentEventMap` with the topics `edit`, `undo`, `plugin:error`, and `page:loaded`, backed by `@suaveplan/events`. Listener registration and removal SHALL be idempotent — registering the same listener twice SHALL fire it once, and `off` for an unregistered listener SHALL be a no-op.
#### Scenario: Edit event carries the edit and version
- **WHEN** a listener is registered for `edit` and a caller runs an edit
- **THEN** the listener is invoked once with `{ edit, version }` matching the appended edit and the new version
**Verified by:** `src/document/document.test.ts::"on/off manage typed listeners idempotently"`
---
### Requirement: Paged memory primitives
The package SHALL export `PAGE_SIZE` (`1024 * 1024` samples) and `BLOCK_SIZE` (`1024` samples) constants, and the primitives `walkPages(doc, channel, startSample, count, visitor)`, `copyPages(doc, channel, startSample, count, target, targetOffset)`, and `readPages(doc, offset?, duration?)`. Pages SHALL be allocated from a reusable pool (`allocatePage` / `releasePage`) to reduce GC pressure during long streams. `walkPages` SHALL invoke the visitor once per overlapping page with the correct intra-page offset and length; `copyPages` SHALL write into a caller-supplied pre-allocated buffer without allocating.
#### Scenario: walkPages visits every overlapping page
- **WHEN** a caller calls `walkPages` over a range spanning three pages
- **THEN** the visitor is invoked exactly three times with offsets and lengths that sum to the requested count
#### Scenario: Page pool reuses released buffers
- **WHEN** a page is released and a new page of the same shape is allocated
- **THEN** the allocator returns the previously released `Float32Array[]` rather than allocating fresh memory
**Verified by:** `src/page/page.test.ts::"walkPages visits each overlapping page with correct offsets"` AND `src/page/page.test.ts::"page pool reuses released buffers"`
---
### Requirement: Plan compilation
The package SHALL export a `PlanEngine` class constructed from an `OpRegistry`. `compile(doc, final?)` SHALL convert `doc.edits` into a `CompiledPlan` (`{ segs, pipeline, totalLen, sampleRate, limit }`) in two passes: a resolve pass that expands high-level edits into primitive edits via each op's `resolve` function, and a plan pass that transforms the `Segment[]` via each op's `plan` function. Ops without a `plan` function SHALL be appended to `pipeline` as process-stage ops. An edit whose `type` is not registered SHALL throw `AudioError` with code `AUDIO_CORE.PLUGIN_NOT_FOUND`. When `final` is false the engine SHALL track a `limit` marking the safe source boundary for incremental decode.
#### Scenario: Identity edit preserves total length
- **WHEN** a document with an identity edit (e.g. `gain` at unity) is compiled
- **THEN** `plan.totalLen` equals `doc.sourceLength`
#### Scenario: Unknown op type throws AUDIO_CORE.PLUGIN_NOT_FOUND
- **WHEN** `compile` encounters an edit whose `type` is not in the registry
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.PLUGIN_NOT_FOUND"` with the offending `type` in `context`
#### Scenario: Compilation is deterministic
- **WHEN** the same document is compiled twice
- **THEN** the two `CompiledPlan` values are deep-equal
**Verified by:** `src/plan/plan-engine.test.ts::"compile resolves and plans edits into a CompiledPlan"` AND `src/plan/plan-engine.test.ts::"compile throws AUDIO_PLUGIN_NOT_FOUND for unknown op"` AND `tests/property.test.ts::"plan compilation is deterministic"`
---
### Requirement: Plan-driven read and stream
`PlanEngine.readPlan(doc, plan, offset?, duration?)` SHALL render a static range into `Float32Array[]` using the compiled segments. `streamPlan(doc, plan, opts?)` SHALL be an async generator yielding `Float32Array[]` blocks of at most `BLOCK_SIZE` samples that, summed, equal the requested range. `ensurePages(doc, plan, offset?, duration?)` SHALL preload any evicted pages a range requires before rendering. `AudioDocument.read()` and `AudioDocument.stream()` SHALL delegate to these methods. An out-of-range page request SHALL throw `AudioError` with code `AUDIO_CORE.PAGE_OUT_OF_RANGE`.
#### Scenario: Stream yields the exact total sample count
- **WHEN** a caller fully drains `streamPlan` for a known range
- **THEN** the concatenated block lengths equal the requested sample count
#### Scenario: Out-of-range read throws AUDIO_CORE.PAGE_OUT_OF_RANGE
- **WHEN** `readPlan` is asked for a range beyond `plan.totalLen`
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.PAGE_OUT_OF_RANGE"`
**Verified by:** `src/plan/plan-engine.test.ts::"streamPlan yields the exact total sample count"` AND `src/plan/plan-engine.test.ts::"readPlan throws AUDIO_PAGE_OUT_OF_RANGE beyond plan length"` AND `tests/property.test.ts::"stream yields exact total sample count"`
---
### Requirement: Block-level process pipeline
The package SHALL export a `ProcessPipeline` class constructed from an `OpRegistry`. `init(pipeline, totalDuration, sampleRate, channels)` SHALL allocate `ProcessState[]`. `patch(state, pipeline)` SHALL hot-swap edit parameter values without re-allocating buffers. `process(input, output, state, blockOffsetSamples, sampleRate)` SHALL run each process op in order over a single block. When the pipeline structure changes between blocks (ops added or removed, detected via a pipeline signature), the pipeline SHALL reinitialise affected processors and crossfade over approximately 20 ms to avoid clicks.
#### Scenario: patch updates parameters without re-allocation
- **WHEN** a caller calls `patch` with the same op set but changed parameter values
- **THEN** the existing `ProcessState[]` buffers are reused and the new parameter values take effect on the next `process` call
#### Scenario: Structural change crossfades
- **WHEN** an op is added to the pipeline between two `process` calls
- **THEN** the pipeline reinitialises and the output transitions over a crossfade window rather than discontinuously
**Verified by:** `src/process/process-pipeline.test.ts::"patch swaps parameters without re-allocating buffers"` AND `src/process/process-pipeline.test.ts::"structural pipeline change triggers a crossfade"`
---
### Requirement: Process-op error isolation
When a process op throws inside `ProcessPipeline.process`, the pipeline SHALL NOT propagate the throw. It SHALL emit a `plugin:error` event carrying `{ plugin, error }` and copy the block's `input` channels into `output` unmodified, so a crashing op degrades to pass-through without corrupting the stream. Subsequent ops in the pipeline SHALL still run on the passed-through block.
#### Scenario: Crashing op degrades to pass-through
- **WHEN** a registered process op throws on a block
- **THEN** a `plugin:error` event fires with the op name, `output` equals `input` for that op's stage, and the stream continues
**Verified by:** `src/process/process-pipeline.test.ts::"crashing process op emits plugin:error and passes input through"`
---
### Requirement: Operation registry and the six plugin slots
The package SHALL export an `OpRegistry` class with `register(descriptor)`, `unregister(name)`, `get(name)`, `list()`, and a static `createWithDefaults()`. An `AudioOpDescriptor` SHALL carry a readonly `name` and optional `params`, `resolve`, `plan`, `process`, `sr`, and `ch` members. Registering a descriptor whose `name` is already registered SHALL replace the prior descriptor. `createWithDefaults()` SHALL return a registry pre-populated with every built-in op.
#### Scenario: register then get round-trips a descriptor
- **WHEN** a caller registers a descriptor and calls `get` with its name
- **THEN** the same descriptor is returned, and it appears in `list()`
#### Scenario: createWithDefaults pre-registers every built-in op
- **WHEN** a caller calls `OpRegistry.createWithDefaults()`
- **THEN** `get` resolves every built-in plan op and process op named in this spec
**Verified by:** `src/registry/op-registry.test.ts::"register/get/unregister/list manage descriptors"` AND `src/registry/op-registry.test.ts::"createWithDefaults pre-registers all built-in ops"`
---
### Requirement: Built-in plan operations
The default registry SHALL ship these plan ops: `trim`, `crop`, `speed`, `stretch`, `reverse`, `insert`, `remove`, `repeat`, `pad`, and `resample`. Each SHALL transform the `Segment[]` so the compiled `totalLen`, `sampleRate`, or `channels` reflect the operation: `trim`/`crop`/`remove` shorten output, `repeat`/`pad`/`insert` lengthen it, `speed`/`stretch` change duration, `reverse` inverts segment order, and `resample` changes `sampleRate` via the op's `sr` transform.
#### Scenario: trim restricts the output range
- **WHEN** a `trim` edit selecting samples `[1000, 2000)` is compiled
- **THEN** `plan.totalLen === 1000` and rendering yields the original samples from that range
#### Scenario: resample changes the plan sample rate
- **WHEN** a `resample` edit to 48000 Hz is compiled on a 44100 Hz document
- **THEN** `plan.sampleRate === 48000`
**Verified by:** `src/registry/built-in/trim.test.ts::"trim restricts output to the selected range"` AND `src/registry/built-in/resample.test.ts::"resample updates the plan sample rate"`
---
### Requirement: Built-in process operations
The default registry SHALL ship these process ops: `gain`, `normalize`, `fade`, `pan`, `mix`, `dither`, and `transform`. Each SHALL implement a `process` function operating block-by-block on `Float32Array[]`: `gain` applies a linear multiplier, `normalize` applies peak or LUFS normalisation, `fade` applies a linear or exponential ramp, `pan` applies a constant-power stereo law, `mix` sums another document's blocks, `dither` applies TPDF dither, and `transform` applies a caller-supplied per-sample function.
#### Scenario: gain scales every sample
- **WHEN** a `gain` op with multiplier `0.5` processes a block
- **THEN** every output sample equals the corresponding input sample multiplied by `0.5`
#### Scenario: pan applies a constant-power law
- **WHEN** a `pan` op at hard-left processes a stereo block
- **THEN** the left channel carries the constant-power-scaled signal and the right channel is silent
**Verified by:** `src/registry/built-in/gain.test.ts::"gain scales every sample by the multiplier"` AND `src/registry/built-in/pan.test.ts::"pan applies a constant-power stereo law"`
---
### Requirement: BlockProcessor interface
The package SHALL export a `BlockProcessor` interface (`name`, `setup(config)`, `process(input, output, ctx)`, `reset()`, `dispose()`) and the supporting `ProcessorConfig` and `BlockContext` types. This is the universal DSP contract implemented by every FX, analysis, and codec-post-processing plugin across the audio family. A reference `NoopBlockProcessor` SHALL ship for tests, copying `input` to `output` unchanged.
#### Scenario: NoopBlockProcessor passes audio through unchanged
- **WHEN** `NoopBlockProcessor.process` runs on a block
- **THEN** `output` is byte-equal to `input`
**Verified by:** `src/process/block-processor.test.ts::"NoopBlockProcessor copies input to output unchanged"`
---
### Requirement: Streaming integration with @suaveplan/streams
`AudioDocument.stream()` and `PlanEngine.streamPlan()` SHALL produce values composable with `@suaveplan/streams` `TransformStream`. The package SHALL expose a helper that wraps a compiled plan plus process pipeline as a `TransformStream<Float32Array[], Float32Array[]>`, so downstream FX can be appended with native stream backpressure.
#### Scenario: Plan renders through a TransformStream
- **WHEN** a compiled plan is wrapped as a `TransformStream` and piped to a sink
- **THEN** the sink receives the same blocks `streamPlan` would yield directly
**Verified by:** `src/stream/stream.test.ts::"compiled plan renders through a @suaveplan/streams TransformStream"`
---
### Requirement: Audio statistics
The package SHALL export `computeStats(pages)` returning `AudioStats` (`{ peak, rms, dcOffset, lufs? }`) over a page set. `peak` SHALL be the maximum absolute sample, `rms` the root-mean-square across all channels, and `dcOffset` the mean sample value. Stats SHALL be attachable to `AudioDocument.stats`.
#### Scenario: computeStats reports peak, rms, and dcOffset
- **WHEN** `computeStats` runs over a known signal (e.g. a unity-amplitude sine)
- **THEN** `peak` is `1`, `rms` is approximately `0.707`, and `dcOffset` is approximately `0`
**Verified by:** `src/stats/stats.test.ts::"computeStats reports peak, rms, and dcOffset for a known signal"`
---
### Requirement: AbortSignal propagation
`AudioDocument.read()`, `AudioDocument.stream()`, `PlanEngine.streamPlan()`, and `PlanEngine.ensurePages()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioError` with code `AUDIO_CORE.STREAM_ABORTED` before any page work; a signal aborted mid-stream SHALL terminate the generator at the next block boundary with the same error.
#### Scenario: Pre-aborted signal short-circuits stream
- **WHEN** `stream()` is called with an already-aborted signal
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.STREAM_ABORTED"` before yielding any block
#### Scenario: Mid-stream abort terminates at the next block boundary
- **WHEN** a signal is aborted after the first block is yielded
- **THEN** the generator throws `AUDIO_CORE.STREAM_ABORTED` at the next block boundary and yields nothing further
**Verified by:** `src/document/document.test.ts::"stream throws AUDIO_STREAM_ABORTED for a pre-aborted signal"` AND `src/plan/plan-engine.test.ts::"streamPlan honours mid-stream abort"`
---
### Requirement: Zod boundary validation
Every public options object — `AudioDocumentOptions`, `AudioPage`, `AudioEdit`, `AudioStats`, `StreamOptions`, `CompiledPlan`, `AudioOpDescriptor`, `ProcessorConfig` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioError` with code `AUDIO_CORE.INVALID_EDIT` carrying the Zod issue list in `context`.
#### Scenario: Invalid options are rejected at the boundary
- **WHEN** `AudioDocument` is constructed with a negative `sampleRate`
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.INVALID_EDIT"` with the Zod issues in `context`
**Verified by:** `src/document/document.test.ts::"constructor rejects invalid options with AUDIO_INVALID_EDIT"`
---
### Requirement: Structured error codes
The package SHALL export `AudioCoreErrorCode`, `AUDIO_CORE_ERROR_METADATA`, and `registerAudioCoreErrorCodes()`, registering — via `@suaveplan/error`'s `defineCodes("AUDIO_CORE", …)` — the codes `AUDIO_CORE.PLUGIN_NOT_FOUND`, `AUDIO_CORE.INVALID_EDIT`, `AUDIO_CORE.STREAM_ABORTED`, `AUDIO_CORE.PAGE_OUT_OF_RANGE`, and `AUDIO_CORE.PROCESS_ERROR`. Code string values are the namespaced `NAMESPACE.REASON` form produced by `defineCodes` — never a hand-written literal. Every error thrown by the package SHALL be an `AudioError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: Every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_CORE_ERROR_METADATA` is asserted thrown (or carried on a report) by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CORE code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `plan.compile`, `process.block`, and `stream.read`; counters `audio_core_edits_total` and `audio_core_stream_blocks_total`; histograms `audio_core_compile_duration_ms` and `audio_core_process_duration_ms`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: Instrumentation is inert without an exporter
- **WHEN** the package is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
**Verified by:** `src/plan/plan-engine.test.ts::"compile records a span and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
---
### Requirement: Subpath exports per feature module
`package.json` SHALL declare one `exports` entry per feature module (`./document/document`, `./page/page`, `./plan/plan-engine`, `./plan/segment`, `./process/process-pipeline`, `./process/block-processor`, `./registry/op-registry`, `./stream/stream`, `./render/render`, `./stats/stats`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
#### Scenario: Each feature module is independently importable
- **WHEN** a consumer imports `@suaveplan/audio-core/plan/plan-engine`
- **THEN** the import resolves to the plan-engine module without pulling the whole package barrel
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
### Requirement: AnalysisOp operator contract
The audio-core package SHALL expose an `AnalysisOp<TConfig, TResult>` operator contract that every `@suaveplan/audio-analysis-*` package implements, and a `createAnalysisOp` builder that constructs conforming operators with Zod-validated config, OTel instrumentation, and AbortSignal-aware cancellation.
`AnalysisOp` is a discriminated operator with: `name: string` (unique within an analysis pipeline), `version: string` (semver), `capabilities: AnalysisCapabilities` (streaming/realtime/blocking flags), `configSchema: z.ZodTypeAny`, `run(doc: AudioDocument, config: TConfig, opts?: { signal?: AbortSignal }): Promise<TResult>`, and `runStream?(source: AsyncIterable<AnalysisFrame>, config: TConfig, opts?: { signal?: AbortSignal }): AsyncIterable<TResult>`.
`AnalysisResult` is the base shape every analyser extends — `{ analyserName: string; analyserVersion: string; sampleRate: number; durationSamples: number; computedAt: number }`. Concrete analyser results add their own fields on top.
#### Scenario: builder validates config via Zod
- **WHEN** `createAnalysisOp({ name, version, capabilities, configSchema, run })` is called and a downstream caller invokes `op.run(doc, badConfig)`
- **THEN** the builder MUST reject with `AudioError("AUDIO_CORE.ANALYSIS_INVALID_CONFIG")` carrying the Zod issues on `context.issues`, before `run` is invoked
#### Scenario: builder instruments runs via OpenTelemetry
- **WHEN** `op.run(doc, validConfig)` is called and succeeds
- **THEN** the builder MUST emit one OTel span named `analysis.${name}` with attributes `{ "analysis.name": name, "analysis.version": version, "audio.sample_rate": doc.sampleRate, "audio.duration_samples": doc.sourceLength }`, increment counter `audio_analysis_runs_total{analysis=${name}}`, and record histogram `audio_analysis_duration_ms{analysis=${name}}`
#### Scenario: builder forwards AbortSignal
- **WHEN** `op.run(doc, validConfig, { signal })` is invoked and `signal.aborted === true` before `run` returns
- **THEN** the inner `run` callback MUST observe the signal and the builder MUST surface the abort as `AudioError("AUDIO_CORE.ANALYSIS_ABORTED")`
#### Scenario: capability flags advertise streaming support
- **WHEN** a caller inspects `op.capabilities.supportsStream`
- **THEN** the value MUST equal `typeof op.runStream === "function"`
**Verified by:** `src/analysis/analysis-op.test.ts`
### Requirement: AnalysisFrame streaming window descriptor
The audio-core package SHALL export an `AnalysisFrame` shape — `{ samples: Float32Array[]; sampleRate: number; channelCount: number; startSample: number; sampleCount: number }` — usable as the streaming-input element type for any `AnalysisOp.runStream` implementation. The shape is types-only; no runtime construction helper is mandated.
#### Scenario: streaming analyser consumes AnalysisFrame iterable
- **WHEN** a spectrum analyser's `runStream(source, config)` is invoked with an `AsyncIterable<AnalysisFrame>`
- **THEN** the analyser MUST process each frame independently and MAY yield zero, one, or many `AnalysisResult`-extending values per input frame
**Verified by:** `src/analysis/analysis-op.test.ts::"runStream yields wrapped results and observes abort between yields"`

Some files were not shown because too many files have changed in this diff Show More