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