Files
2026-05-27 23:29:53 +01:00

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", 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"