Initial Commit
This commit is contained in:
129
openspec/archive/2026-05-18-add-api-plugin-audit/proposal.md
Normal file
129
openspec/archive/2026-05-18-add-api-plugin-audit/proposal.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Add @suaveplan/api-plugin-audit — write-through audit trail for @suaveplan/api
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-audit` (NEW, server-tier)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Spec delta**: ./specs/api-plugin-audit/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/api` (Hono-based orchestrator), `@suaveplan/api-plugin` (`definePlugin` foundation + `ServiceRegistry`), and `@suaveplan/audit-logging` (universal `AuditLogger` with HMAC-SHA256 chain-integrity, pluggable `AuditStorageAdapter`, retention, GDPR export). What is missing is the glue: a `definePlugin` consumer that installs a global Hono middleware to record an audit event for every request — method, path, status, duration, response size, request id, principal id (from `c.get("user")?.id`), tenant id (from `c.get("tenant")?.id`) — and exposes `auditService` in `ServiceRegistry` plus an `audit` slot on the Hono `ContextVariableMap` for handler-level events.
|
||||
|
||||
This is the compliance/audit half of the api-plugin trio (auth → audit → rate-limit). The upstream `enterprise/api-plugin-audit.md` (126 lines) originally described an in-memory `AuditService`; this genesis package replaces that with the canonical write-through over `@suaveplan/audit-logging`, picking up its chain-integrity, GDPR export, retention, and pluggable sinks for free.
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package at `0.1.0`. ADDED:
|
||||
|
||||
### Plugin factory + descriptor
|
||||
|
||||
- `createAuditPlugin(options: AuditPluginOptions): PluginDescriptor<AuditPluginConfig>` — Zod-validated factory. Invalid input → `AuditError(AUDIT_PLUGIN_INVALID_INPUT)` carrying `ZodError` as `cause`. Descriptor declares `name: "audit"`, version, defaults, lifecycle hooks.
|
||||
- `AuditPluginOptions` (Zod-validated): `{ logger?: LoggerLike; sink?: AuditStorageAdapter (default: MemoryAuditAdapter); secretKey?: string (default: generated dev key with warning); routePrefix?: string (default "/"); samplingRate?: number 0..1 (default 1); excludePaths?: readonly string[] (default ["/health","/healthz","/ready","/openapi.json"]); redactQueryParams?: readonly string[]; redactHeaders?: readonly string[]; redactBodyFields?: readonly string[]; recordResponseSize?: boolean (default true); recordRequestBody?: boolean (default false); enableChainIntegrity?: boolean (default false); }`.
|
||||
|
||||
### Sink injection
|
||||
|
||||
Caller supplies a real `AuditStorageAdapter` (file/HTTP/DB/SurrealDB/S3). Default is `MemoryAuditAdapter` from `@suaveplan/audit-logging` for dev. In production, operators wire a durable sink. The plugin owns the `AuditLogger` lifecycle (constructed in `onInit`, retained in plugin state, exposed via `auditService` and the `audit` context slot).
|
||||
|
||||
### Global request-audit middleware
|
||||
|
||||
`onInit` installs a single `app.use("*", ...)` middleware that, for every non-excluded request:
|
||||
|
||||
1. Captures `Date.now()` start, request id (from `c.get("requestId")` or `c.req.header("x-request-id")` or generated ULID-like), redacted query, redacted headers, optionally redacted body.
|
||||
2. `await next()`.
|
||||
3. Records: `method`, `path`, `status` (from `c.res.status`), `duration_ms`, `response_size` (from `Content-Length` header when present), `request_id`, `principal_id` (`c.get("user")?.id`), `tenant_id` (`c.get("tenant")?.id`), `severity` (info < 400, warning < 500, error otherwise).
|
||||
4. Honors `samplingRate` (Math.random gate) — when skipped, neither record nor sink call happens.
|
||||
5. Honors `excludePaths` (exact match — health probes, openapi doc).
|
||||
|
||||
The middleware never throws into the request path — a sink failure logs at warn via `LoggerLike` and increments `audit.sink_failures_total` but the request response is unaffected.
|
||||
|
||||
### Per-handler audit helper
|
||||
|
||||
`c.get("audit")` returns an `AuditContext` instance exposing `log(event: Partial<AuditEvent>)` and `getLogger(): AuditLogger`. Handlers fire explicit, semantic events (e.g. user-deleted, vault-unlock) alongside the implicit request audit. The package augments Hono's `ContextVariableMap` with `audit: AuditContext`.
|
||||
|
||||
### Service registry slot
|
||||
|
||||
- `ctx.registerService("auditService", auditService)` where `auditService: AuditService` exposes `getLogger()`, `log(event)`, `query(opts)`, `verifyChainIntegrity(events)`, `exportGDPRData(userId, signal?)`, `applyRetentionPolicy(signal?)`. ServiceRegistry module augmentation declares the slot.
|
||||
|
||||
### Redaction
|
||||
|
||||
- `redactPayload(payload, rules)` — accepts query/header/body field-name lists, returns a shallow-redacted clone replacing matched values with `[REDACTED]`. Header matching is case-insensitive. Defaults cover common secret-bearing names (`authorization`, `cookie`, `set-cookie`, `x-api-key`, plus body `password`, `secret`, `token`, plus query `token`, `code`, `state`).
|
||||
- Uses `@suaveplan/data-masking`'s `maskObject` for body-field traversal when caller supplies custom rules expressed as `MaskRule[]`; otherwise the bundled fast-path covers the default name list without pulling the masking-rule machinery.
|
||||
|
||||
### Errors + OTel
|
||||
|
||||
- `AUDIT_PLUGIN_CODES` (`defineCodes("AUDIT_PLUGIN", [...])`): `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SINK_INIT_FAILED`, `SINK_WRITE_FAILED`. Every code raised by a production code path.
|
||||
- OTel: counter `audit.requests_recorded_total{outcome,sampled}`, counter `audit.sink_failures_total`, counter `audit.requests_excluded_total`, histogram `audit.middleware_duration_ms`.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
`.`, `./plugin/plugin`, `./middleware/audit-middleware`, `./services/audit-service`, `./context/audit-context`, `./redact/redact`, `./schemas/options`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **HTTP routes for querying audit** (e.g. `GET /audit?...`) — the in-process `auditService` + sink-of-choice already cover query; a follow-up `api-plugin-audit-routes` could publish authenticated read endpoints (must depend on `api-plugin-auth`).
|
||||
- **Real durable sinks (SurrealDB / HTTP / SIEM forwarder)** — the package exposes the `AuditStorageAdapter` contract from `@suaveplan/audit-logging`; siblings ship the concrete sinks.
|
||||
- **Hash-chain verification CLI / scheduler-driven retention** — `auditService.verifyChainIntegrity` / `applyRetentionPolicy` are exposed; orchestrating them on a cron belongs in `@suaveplan/job-scheduler` consumers, not this plugin.
|
||||
- **Frontend UI** — backend only.
|
||||
- **PII pseudonymisation via `@suaveplan/data-masking` MaskRules in the request path** — the redactor uses a fast `[REDACTED]` substitution for the request-audit path; full mask-rule machinery is exposed for handler-level `c.get("audit").log({ details: maskObject(payload, rules) })` flows.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/api-plugin-audit.md` (canonical, 126 lines).
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Hono plugin wrapper (`definePlugin`) | L44-L57 | Upstream flagged the gap that this package fills |
|
||||
| Dependency on `@suaveplan/api` | L46 | Verbatim — package must `definePlugin` not be a standalone service |
|
||||
| `AuditEntry` records with severity + action | L12, L22 | Replaced upstream's bespoke types with `@suaveplan/audit-logging`'s `AuditEvent` + `AuditSeverity` (L51 explicitly notes the overlap) |
|
||||
| Wrap `@suaveplan/audit-logging` | L51, L56 | Upstream identified the overlap with the universal package; this change resolves it by making `api-plugin-audit` a wrapper |
|
||||
| HTTP routes for query/export | L62 | Scoped out per "Out of scope"; surfaced through `auditService` for now |
|
||||
| Auth-gated read endpoints | L63 | Scoped out — gated behind future `api-plugin-audit-routes` |
|
||||
| Retention enforcement via scheduler | L64 | `auditService.applyRetentionPolicy` exposed; orchestration is consumer responsibility |
|
||||
| PII masking via data-masking | L65 | Implemented via `redactPayload` + handler-level `maskObject` integration |
|
||||
| Append-only / hash-chain integrity | L61, L84 | Inherited from `@suaveplan/audit-logging`'s `enableChainIntegrity` option |
|
||||
| Globally-unique IDs (ULID/UUID) | L60 | Inherited — `AuditLogger` uses `crypto.randomUUID()` by default |
|
||||
| OTel correlation | L92 | Counters + histogram emitted from middleware |
|
||||
| 100% test + docs coverage | L69, L70 | Genesis-wide policy enforced via gates |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `ServiceRegistry` typed slot for `auditService` | Genesis `@suaveplan/api-plugin` augmentation pattern (mirror of `api-plugin-auth` `authService` slot) |
|
||||
| Hono `ContextVariableMap` augmentation for `audit` slot | Genesis context-augmentation pattern (mirror of api-plugin-auth `user`/`session`) |
|
||||
| Sampling-rate gate | Genesis-wide policy: production audit volumes require head-end sampling |
|
||||
| `excludePaths` for health probes | Avoids audit-noise on liveness/readiness traffic |
|
||||
| Middleware never throws into request path | Standard observability rule — telemetry failures must not 500 |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta has a `**Verified by:**` line. Test files that will exist after this change ships:
|
||||
|
||||
- `src/plugin/plugin.test.ts` — factory shape, Zod validation, lifecycle hook presence
|
||||
- `src/plugin/on-init.test.ts` — lifecycle: service resolution, sink wiring, middleware install
|
||||
- `src/middleware/audit-middleware.test.ts` — per-request behavior: capture, redact, sample, exclude, never-throw
|
||||
- `src/services/audit-service.test.ts` — log/query/export/retention proxies
|
||||
- `src/services/audit-service.spec-conformance.test.ts` — ServiceRegistry slot typing
|
||||
- `src/context/audit-context.test.ts` — `c.get("audit").log(...)`
|
||||
- `src/redact/redact.test.ts` — case-insensitive headers, query, body fields
|
||||
- `src/schemas/options.test.ts` — Zod boundaries
|
||||
- `src/otel/instruments.test.ts` — counters/histogram emit
|
||||
- `src/api-plugin-audit.spec-conformance.test.ts` — every `AUDIT_PLUGIN_*` code thrown from prod
|
||||
- `tests/integration/full-boot.test.ts` — real api + plugin + sample route → sink receives event with redacted secrets, principal/tenant/status/duration correct
|
||||
- `tests/integration/sampling.test.ts` — 50% sampling over 1000 requests in `[400, 600]`
|
||||
- `tests/dep-graph.test.ts` — every `.ts` has sibling `.md`; every subpath resolves
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. The legacy upstream `@suaveplan/api-plugin-audit` shipped a different surface (in-memory `AuditService` with no plugin wrapper). Genesis ships zero existing consumers of the legacy surface, so this is a clean rewrite at `0.1.0`.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/api-plugin-audit` — package does not yet exist).
|
||||
|
||||
### Catalog additions required
|
||||
|
||||
None — all dependencies (`@suaveplan/api`, `@suaveplan/api-plugin`, `@suaveplan/audit-logging`, `@suaveplan/data-masking`, `@suaveplan/error`, `@suaveplan/types`, `hono`, `zod`, `@opentelemetry/api`, `tslib`) already exist in catalog/workspace.
|
||||
@@ -0,0 +1,178 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
68
openspec/archive/2026-05-18-add-api-plugin-audit/tasks.md
Normal file
68
openspec/archive/2026-05-18-add-api-plugin-audit/tasks.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Tasks — add-api-plugin-audit
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-audit.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/specs/api-plugin-audit/spec.md` (does not exist — net-new)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/api-plugin-audit/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/api/api-plugin-audit/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`
|
||||
- [x] 2.2 Error codes — `src/error-codes/error-codes.ts` (`AUDIT_PLUGIN_CODES` + `AuditPluginError`)
|
||||
- [x] 2.3 Types — `src/types/types.ts` (`AuditPluginOptions`, `AuditPluginConfig`, `AuditService`, `AuditContext`, `RedactionRules`, etc.)
|
||||
- [x] 2.4 Zod schemas — `src/schemas/options.ts`
|
||||
- [x] 2.5 Redaction — `src/redact/redact.ts` (`redactPayload`, default lists)
|
||||
- [x] 2.6 Audit context — `src/context/audit-context.ts` (factory + Hono `ContextVariableMap` augmentation)
|
||||
- [x] 2.7 Audit service — `src/services/audit-service.ts` (wraps `AuditLogger` from `@suaveplan/audit-logging`) + `registry-augmentation.ts`
|
||||
- [x] 2.8 OTel — `src/otel/instruments.ts` (counters + histogram, in-memory test exporter wiring)
|
||||
- [x] 2.9 Middleware — `src/middleware/audit-middleware.ts` (global request audit, sampling, exclude, never-throw, redact)
|
||||
- [x] 2.10 Plugin factory — `src/plugin/plugin.ts` (Zod-validated `createAuditPlugin`, `onInit` lifecycle, sink wiring)
|
||||
- [x] 2.11 Barrel `src/index.ts` with side-effect imports (registry + context augmentations) + explicit named re-exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests at `src/<feature>/<feature>.test.ts` covering every scenario
|
||||
- [x] 3.2 Spec-conformance tests at `src/<feature>/<feature>.spec-conformance.test.ts` pinning type-level / observability / strict-mode / lifecycle scenarios
|
||||
- [x] 3.3 Integration tests at `tests/integration/full-boot.test.ts` (real api + plugin + sample route) + `tests/integration/sampling.test.ts` (50% sampling band)
|
||||
- [x] 3.4 `tests/dep-graph.test.ts` — sibling `.md`, subpath export resolution
|
||||
- [x] 3.5 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 4.3 Do NOT touch `CLAUDE.md`, `MANIFEST.md`, `STATUS.md` per task instructions
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-audit --force` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-audit` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-audit` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-audit --force` — green
|
||||
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts` — exit 0
|
||||
|
||||
## 6. Pre-archive 9-row checklist (skill SKILL.md)
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary
|
||||
- [x] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
|
||||
- [x] 6.4 Strict-mode behaviors tested both on and off
|
||||
- [x] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 6.6 Lifecycle dedup tested where claimed (in-flight Promise, idempotent set/load)
|
||||
- [x] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
- [x] 6.8 No `export *` in any feature-module barrel (§6a)
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports in test files (§7)
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-audit/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-api-plugin-audit/` → `openspec/archive/2026-05-18-add-api-plugin-audit/`
|
||||
- [x] 7.3 Skip MANIFEST.md / STATUS.md refresh per task instructions
|
||||
Reference in New Issue
Block a user