13 KiB
Capability Spec Delta: api-plugin-audit (add-api-plugin-audit)
Package: @suaveplan/api-plugin-audit
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time.
ADDED 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",onInitandonCloseare 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 withcause 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
onInitruns against a context whose ServiceRegistry has noapp - THEN
AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)is thrown withcontext.missingcontaining"app"
Scenario: onInit registers auditService and installs middleware
- WHEN
onInitruns withapppresent and aMemoryAuditAdaptersink - THEN
ctx.getService("auditService")returns a non-undefined service AND a subsequent request throughapp.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" })andc.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_msis 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, andrecordRequestBody: truewith 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
/healtharrives with defaultexcludePaths - THEN no event is saved AND
audit.requests_excluded_totalincrements 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_totalincrements AND the logger receives awarnmessage
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 ANDaudit.middleware_duration_mshas at least one observation
Scenario: excluded counter increments for health checks
- WHEN a request to
/healthflows through the middleware - THEN
audit.requests_excluded_totalhas at least one observation ANDaudit.requests_recorded_totaldoes 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.tsboots a realOpenAPIHono+PluginRegistry+createAuditPlugin({ sink: memoryAdapter, secretKey, redactQueryParams: ["token"] })+ a sample route that setsuser/tenantand callsc.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"