first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# @suaveplan/api-plugin-audit - Hono plugin wrapping @suaveplan/audit-logging as a write-through audit trail for @suaveplan/api. Installs a global middleware recording method/path/status/duration/response-size/request-id/principal-id/tenant-id per request, exposes auditService in ServiceRegistry, and surfaces c.get("audit") for handler-level events. Configurable redaction, sampling, and exclusion. Sink is caller-supplied (any AuditStorageAdapter); default is in-memory for dev. Server-tier — Bun + Node.
Hono plugin that wraps `@suaveplan/audit-logging` as a write-through audit trail for `@suaveplan/api`. Installs a global per-request audit middleware (method / path / status / duration / response size / request id / principal id / tenant id), exposes `auditService` in the host `ServiceRegistry`, and surfaces `c.get("audit")` for handler-level semantic events. Sink is caller-supplied; default is an in-memory adapter for development.
<!-- begin:auto-docs -->
## Documentation tree
- [audit-context](./src/context/audit-context.md) — Builds the per-request `AuditContext` handle the audit middleware attaches to `c.set("audit", context)` before invoking…
- [context-augmentation](./src/context/context-augmentation.md) — TypeScript module augmentation that adds the `audit: AuditContext` slot to Hono's `ContextVariableMap`. Without it, `c.…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-plugin-audit` and the concrete `AuditPluginError` class used…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-plugin-audit` and the concrete `AuditPluginError` class used…
- [middleware](./src/middleware/audit-middleware.md) — Builds the global Hono middleware that records one audit event per non-excluded request through the underlying `AuditLo…
- [audit-middleware](./src/middleware/audit-middleware.md) — Builds the global Hono middleware that records one audit event per non-excluded request through the underlying `AuditLo…
- [otel](./src/otel/instruments.md) — Owns the OpenTelemetry instrument bundle the audit plugin emits to every request lifecycle. Resolving the instruments l…
- [instruments](./src/otel/instruments.md) — Owns the OpenTelemetry instrument bundle the audit plugin emits to every request lifecycle. Resolving the instruments l…
- [plugin](./src/plugin/plugin.md) — Top-level factory for the audit plugin. `createAuditPlugin(options)` Zod-validates the caller's options at the boundary…
- [plugin](./src/plugin/plugin.md) — Top-level factory for the audit plugin. `createAuditPlugin(options)` Zod-validates the caller's options at the boundary…
- [redact](./src/redact/redact.md) — Lightweight, allocation-free-ish payload redactor used by the audit middleware to scrub secret-bearing fields from per-…
- [redact](./src/redact/redact.md) — Lightweight, allocation-free-ish payload redactor used by the audit middleware to scrub secret-bearing fields from per-…
- [schemas](./src/schemas/options.md) — Zod schema that validates `AuditPluginOptions` at the `createAuditPlugin` factory boundary and produces the resolved `A…
- [options](./src/schemas/options.md) — Zod schema that validates `AuditPluginOptions` at the `createAuditPlugin` factory boundary and produces the resolved `A…
- [audit-service](./src/services/audit-service.md) — Constructs the `AuditService` adapter the plugin registers in `ServiceRegistry` under the `auditService` slot. The adap…
- [registry-augmentation](./src/services/registry-augmentation.md) — Declares the `auditService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScri…
- [types](./src/types/types.md) — Central, runtime-free declaration of the interfaces the plugin's lifecycle, middleware, services, and context helpers d…
- [types](./src/types/types.md) — Central, runtime-free declaration of the interfaces the plugin's lifecycle, middleware, services, and context helpers d…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-audit
```
## Quick start
```ts
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { createAuditPlugin } from "@suaveplan/api-plugin-audit";
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY,
samplingRate: 1, // record every request
excludePaths: ["/health", "/openapi.json"],
});
// Register on your @suaveplan/api PluginRegistry alongside auth/tenant/etc.
```
Inside a route handler:
```ts
app.post("/users/:id/delete", async (c) => {
const audit = c.get("audit");
await audit.log({
type: "user.deleted",
severity: "warning",
actor: { userId: c.get("user")?.id },
target: { type: "user", id: c.req.param("id") },
});
return c.json({ ok: true });
});
```
## Modules
- [plugin/plugin](./src/plugin/plugin.md) — `createAuditPlugin` factory.
- [middleware/audit-middleware](./src/middleware/audit-middleware.md) — per-request middleware.
- [services/audit-service](./src/services/audit-service.md) — `auditService` adapter.
- [services/registry-augmentation](./src/services/registry-augmentation.md) — `ServiceRegistry` slot.
- [context/audit-context](./src/context/audit-context.md) — handler-level `c.get("audit")`.
- [context/context-augmentation](./src/context/context-augmentation.md) — Hono `ContextVariableMap` slot.
- [redact/redact](./src/redact/redact.md) — payload redactor.
- [otel/instruments](./src/otel/instruments.md) — OpenTelemetry counters + histogram.
- [schemas/options](./src/schemas/options.md) — Zod options schema + defaults.
- [error-codes/error-codes](./src/error-codes/error-codes.md) — `AUDIT_PLUGIN_*` registry.
## Features
- One audit event per non-excluded request, recorded through the underlying `AuditLogger` (chain-integrity, GDPR export, retention all inherited).
- Configurable redaction (case-insensitive headers, exact query / body field names).
- Head-end sampling gate with caller-supplied (or default `Math.random`) RNG.
- Excluded paths skip recording but still install the `audit` context slot.
- Middleware NEVER throws into the request response — sink failures are logged at warn and counted via `audit.sink_failures_total`.
- `auditService` registered with `ctx.registerService("auditService", ...)` and exposed through the typed `ServiceRegistry` slot.
- Hono `ContextVariableMap` augmented with `audit: AuditContext` so handlers fire explicit events without import boilerplate.
- Four OpenTelemetry instruments: `audit.requests_recorded_total{outcome,sampled}`, `audit.requests_excluded_total`, `audit.sink_failures_total`, `audit.middleware_duration_ms`.

View File

@@ -0,0 +1,51 @@
# context/audit-context
## Purpose
Builds the per-request `AuditContext` handle the audit middleware attaches to `c.set("audit", context)` before invoking `next()`. Handlers call `c.get("audit").log({...})` to fire explicit, semantic audit events alongside the implicit per-request audit that the middleware writes. This is the public extension point for application code — instead of importing `auditService` and threading it through every route, handlers reach into the Hono context.
The two-method surface (`log` + `getLogger`) is intentionally small so handler code stays focused on the event payload rather than the plumbing. The same underlying `AuditLogger` instance is reused across every request — there is one logger per app, not one per request — so chain-integrity links hold across handler-level and middleware-level events.
## Features
- Stable two-method surface (`log`, `getLogger`).
- Hono `ContextVariableMap` augmentation declared in `./context-augmentation.ts` so `c.get("audit")` types as `AuditContext` (not `unknown`).
- Zero per-request allocation beyond the lightweight wrapper object (no closures over the request, so the object can be cached if needed).
## Usage: Basic
```ts
// In a route handler:
app.post("/users/:id/delete", async (c) => {
const audit = c.get("audit");
await audit.log({
type: "user.deleted",
severity: "warning",
actor: { userId: c.get("user")?.id },
target: { type: "user", id: c.req.param("id") },
});
return c.json({ deleted: true });
});
```
## Usage: Advanced
```ts
// Reach the underlying logger for advanced ops:
app.get("/admin/audit-chain/verify", async (c) => {
const logger = c.get("audit").getLogger();
const events = await logger.query({ limit: 1000, order: "asc" });
const verdict = await logger.verifyChainIntegrity(events);
return c.json(verdict);
});
```
## API Reference
- `createAuditContext(deps: { logger: AuditLogger }): AuditContext`
- `log(event)` → `Promise<AuditEvent>`
- `getLogger()` → `AuditLogger`
## Implementation Notes
`AuditContext` does not carry the request, the principal, or the tenant. Handlers fill `actor.userId`, `actor.sessionId`, `tenantId`, and so on from `c.get("user")` / `c.get("tenant")` themselves. The middleware's per-request event populates those fields automatically; handler-level events stay explicit because the handler knows the semantic of the event (which user is the actor versus the target) better than the middleware does.

View File

@@ -0,0 +1,58 @@
# context/context-augmentation
## Purpose
TypeScript module augmentation that adds the `audit: AuditContext` slot to Hono's `ContextVariableMap`. Without it, `c.get("audit")` types as `unknown` and every handler call site needs an `as AuditContext` cast — defeating the point of the per-request helper. Importing the package barrel of `@suaveplan/api-plugin-audit` pulls this file in as a side effect, so handlers and downstream middlewares see the typed slot automatically.
The augmentation is intentionally narrow: it declares only `audit`. The `user`, `tenant`, and `requestId` slots that the middleware reads are owned by sibling plugins (`@suaveplan/api-plugin-auth`, `@suaveplan/api-tenant`, `@suaveplan/api`). TypeScript merges declarations across modules, so coexistence is safe — every plugin contributes its own slot to the same map.
## Features
- Declares `audit: AuditContext` on Hono's `ContextVariableMap`.
- Zero runtime cost — pure TypeScript.
- Side-effect imported by the package barrel.
## Usage: Basic
```ts
// Anywhere in the consuming app:
import "@suaveplan/api-plugin-audit";
app.post("/things", async (c) => {
const audit = c.get("audit"); // typed AuditContext
await audit.log({ type: "thing.created", severity: "info", actor: {} });
return c.json({ ok: true });
});
```
## Usage: Advanced
```ts
// In a handler that combines `user` (auth plugin) and `audit`:
import "@suaveplan/api-plugin-auth";
import "@suaveplan/api-plugin-audit";
app.delete("/vaults/:id", async (c) => {
const principal = c.get("user");
const audit = c.get("audit");
if (principal === null) return c.text("unauthenticated", 401);
await audit.log({
type: "vault.deleted",
severity: "warning",
actor: { userId: principal.id },
target: { type: "vault", id: c.req.param("id") },
});
return c.json({ deleted: true });
});
```
## API Reference
This file contributes one TypeScript `declare module "hono"` block adding:
- `audit: AuditContext` on `interface ContextVariableMap`.
## Implementation Notes
The augmentation declares `audit` as a non-optional slot. The middleware always sets it before calling `next()` (even for excluded paths — the slot is populated so handlers that opt into auditing on a normally-excluded path can still call `c.get("audit").log(...)`). If you mount the plugin AFTER user handlers in your middleware chain, `c.get("audit")` will be `undefined` at runtime even though TypeScript types it as `AuditContext`. The recommended fix is to register the audit plugin first; the `definePlugin` priority field can enforce this.

View File

@@ -0,0 +1,62 @@
# error-codes/error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for `@suaveplan/api-plugin-audit` and the concrete `AuditPluginError` class used by every production code path. The registry is shared with `@suaveplan/error`'s singleton via `defineCodes("AUDIT_PLUGIN", [...])`, so every code surfaces in the unified `ErrorCodes` table consumed by logging, metrics, and translation layers.
Every code documented here corresponds to a real failure mode the plugin can surface at the call site. Consumers branch on the string code (`error.code === AUDIT_PLUGIN_CODES.INVALID_INPUT`) rather than `instanceof` checks against private subclasses.
## Features
- Four stable codes: `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SINK_INIT_FAILED`, `SINK_WRITE_FAILED`.
- `AuditPluginError` carries `cause`, `context`, and the `code` discriminant via the `SuaveplanError` base.
- Every code is raised by at least one production code path and pinned by `src/api-plugin-audit.spec-conformance.test.ts`.
## Usage: Basic
```ts
import {
AUDIT_PLUGIN_CODES,
AuditPluginError,
} from "@suaveplan/api-plugin-audit/error-codes/error-codes";
throw new AuditPluginError(
AUDIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"audit plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
```
## Usage: Advanced
```ts
import {
AUDIT_PLUGIN_CODES,
AuditPluginError,
} from "@suaveplan/api-plugin-audit/error-codes/error-codes";
try {
await sink.save(event);
} catch (cause) {
const err = new AuditPluginError(
AUDIT_PLUGIN_CODES.SINK_WRITE_FAILED,
"audit sink rejected save()",
{ cause, context: { eventId: event.id } }
);
logger.warn(err.message, { code: err.code, cause: err.cause });
}
```
## API Reference
- `AUDIT_PLUGIN_CODES` — frozen record from `defineCodes("AUDIT_PLUGIN", [...])`.
- `AuditPluginErrorCode` — string-literal union of the registered codes.
- `class AuditPluginError extends SuaveplanError<AuditPluginErrorCode>` — public constructor accepts `(code, message, options?)`.
## Implementation Notes
The registry is small on purpose. Validation failures map to `INVALID_INPUT`; structural prerequisite failures map to `SERVICE_NOT_AVAILABLE`; sink construction failures map to `SINK_INIT_FAILED`; per-request sink rejections map to `SINK_WRITE_FAILED`. The middleware path never re-throws `SINK_WRITE_FAILED` — it logs and counts so the request response is unaffected (telemetry must not 500 the caller).

View File

@@ -0,0 +1,102 @@
# middleware/audit-middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the global Hono middleware that records one audit event per non-excluded request through the underlying `AuditLogger`. The middleware also installs the `c.set("audit", ...)` slot before invoking `next()` so handlers can fire explicit, semantic events through `c.get("audit").log(...)` regardless of whether the request itself was recorded. This file is the per-request hot path; everything else in the plugin exists to support it.
The middleware never throws into the request response — sink failures are caught, logged at warn, and surfaced via the `audit.sink_failures_total` counter. Telemetry must not 500 the caller.
## Features
- Per-request event capture: method, path, status, duration (ms), request id, principal id, tenant id, response size, severity (info < 400, warning < 500, error otherwise).
- Configurable redaction of query, headers, and (opt-in) JSON body via `redactPayload`.
- Head-end sampling gate via `samplingRate` and caller-supplied (or default `Math.random`) RNG.
- Exclude paths (exact match) skip recording while still installing the `audit` context slot.
- Never throws — sink failures logged at warn, counted, propagated through `audit.requests_recorded_total{outcome="error",sampled="true"}`.
- Re-throws any error the downstream handler raised AFTER recording the audit (so the host error-handler still runs).
- Records four OTel observations: recorded counter (with `outcome` + `sampled` labels), excluded counter, sink-failures counter, duration histogram.
## Usage: Basic
```ts
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit/middleware/audit-middleware";
import { Hono } from "hono";
const app = new Hono();
const logger = new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY!,
});
app.use(
"*",
createAuditMiddleware({
logger,
config: {
sink: logger.getLogger ? logger : (undefined as never),
secretKey: "",
samplingRate: 1,
excludePaths: ["/health"],
redactQueryParams: ["token"],
redactHeaders: ["authorization"],
redactBodyFields: ["password"],
recordResponseSize: true,
recordRequestBody: false,
enableChainIntegrity: false,
rng: Math.random,
},
hostLogger: console as never,
})
);
```
## Usage: Advanced
```ts
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit/middleware/audit-middleware";
// Deterministic sampling for tests:
let i = 0;
const seq = [0.1, 0.9, 0.4, 0.8];
const rng = () => seq[i++ % seq.length] as number;
const middleware = createAuditMiddleware({
logger: new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
}),
config: {
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
samplingRate: 0.5,
excludePaths: [],
redactQueryParams: ["secret"],
redactHeaders: ["authorization"],
redactBodyFields: ["password"],
recordResponseSize: true,
recordRequestBody: true, // includes JSON bodies in the audit detail
enableChainIntegrity: true,
rng,
},
hostLogger: console as never,
});
```
## API Reference
- `createAuditMiddleware(deps: { logger: AuditLogger; config: AuditPluginConfig; hostLogger: LoggerLike }): MiddlewareHandler`
- `logger` — the underlying `AuditLogger` constructed in `onInit`.
- `config` — the resolved `AuditPluginConfig`.
- `hostLogger` — the logger used to surface sink-write failures at warn.
The returned middleware is a standard Hono `MiddlewareHandler` you can register via `app.use("*", ...)`.
## Implementation Notes
`c.set("audit", ...)` is set BEFORE the excluded-path / sampling gates so handlers on excluded paths can still emit explicit events. The middleware reads the response status from `c.res?.status` after `next()`; when the downstream handler throws, the middleware captures the throw, sets status to 500 in the audit record, then re-throws so the host error handler still produces the actual response. JSON body capture clones `c.req.raw` to avoid consuming the body the handler needs to read; non-JSON bodies are not captured (binary uploads would balloon the audit volume). Sink-write failures never propagate to the request — they update three OTel counters and a warn-level log instead.

View File

@@ -0,0 +1,56 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument bundle the audit plugin emits to every request lifecycle. Resolving the instruments lazily on first call lets test harnesses install a custom `MeterProvider` BEFORE the production module loads, so the in-memory exporter captures every observation. Without this lazy resolution, the meter would bind to the no-op default provider at import time and tests would see no metrics.
The four instruments cover the entire surface needed to operate the plugin in production: per-request recorded counter (labelled by outcome and sampled flag), excluded-path counter, sink-failure counter, and a duration histogram.
## Features
- Lazy, cached resolution — first call constructs the meter + four instruments; subsequent calls return the same bundle.
- `resetAuditPluginInstruments()` test hook drops the cache so a new `MeterProvider` can be picked up between tests.
- Counter and histogram surfaces follow the OTel semantic-conventions naming style (`audit.<noun>_<unit>` for histograms, `<noun>_total` for counters).
## Usage: Basic
```ts
import { getAuditPluginInstruments } from "@suaveplan/api-plugin-audit/otel/instruments";
const instruments = getAuditPluginInstruments();
instruments.requestsRecorded.add(1, { outcome: "success", sampled: "true" });
instruments.middlewareDuration.record(12.4);
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
getAuditPluginInstruments,
resetAuditPluginInstruments,
} from "@suaveplan/api-plugin-audit/otel/instruments";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
resetAuditPluginInstruments();
const instruments = getAuditPluginInstruments();
instruments.sinkFailures.add(1);
await provider.forceFlush();
```
## API Reference
- `interface AuditPluginInstruments { meter, requestsRecorded, requestsExcluded, sinkFailures, middlewareDuration }`.
- `getAuditPluginInstruments(): AuditPluginInstruments` — cached resolver.
- `resetAuditPluginInstruments(): void` — drops the cache.
## Implementation Notes
The lazy cache is keyed on a single module-level `let` binding; concurrent first-callers see the same instance because the resolver is synchronous. Histogram unit is explicitly declared `ms` so OTel collectors don't have to infer it from the metric name. The labels on `requestsRecorded` are kept low-cardinality (`outcome ∈ {success, error}` × `sampled ∈ {true, false}`) so the cardinality budget for downstream backends stays bounded.

View File

@@ -0,0 +1,62 @@
# plugin/plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level factory for the audit plugin. `createAuditPlugin(options)` Zod-validates the caller's options at the boundary, builds the resolved `AuditPluginConfig`, and returns a typed `PluginDescriptor<AuditPluginConfig>` whose `onInit` hook performs the canonical 4-step lifecycle: resolve `app`, construct `AuditLogger`, register `auditService`, install the global middleware. The descriptor's `onClose` clears the cached logger reference.
This is the only entry point operators interact with. Everything else in the package is reachable through the descriptor or the ServiceRegistry slot the descriptor installs.
## Features
- Zod-validated options via `AuditPluginOptionsSchema`; failures throw `AuditPluginError(AUDIT_PLUGIN_INVALID_INPUT)` with the `ZodError` as `cause`.
- Defaults supply `MemoryAuditAdapter` when no sink is injected (dev fallback).
- Warns when the dev-default secret key is used so production operators notice.
- `onInit` raises `AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE` when `app` is missing, listing `["app"]` in `context.missing`.
- `onInit` raises `AUDIT_PLUGIN_SINK_INIT_FAILED` when `new AuditLogger(...)` throws (e.g. missing/invalid secret key).
- `onInit` is idempotent in the sense that re-invocation rebuilds the logger and re-installs the middleware (Hono `app.use("*", ...)` chaining is additive, so callers should treat the descriptor as one-shot per registry).
## Usage: Basic
```ts
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { createAuditPlugin } from "@suaveplan/api-plugin-audit";
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY,
});
// register on your PluginRegistry alongside auth, tenant, etc.
```
## Usage: Advanced
```ts
import { createAuditPlugin } from "@suaveplan/api-plugin-audit";
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY,
samplingRate: 0.1, // record 10% of requests
excludePaths: ["/health", "/metrics", "/openapi.json"],
redactBodyFields: ["password", "ssn", "card_number"],
redactHeaders: ["authorization", "cookie", "x-internal-key"],
redactQueryParams: ["token", "code", "state", "session"],
recordRequestBody: true,
enableChainIntegrity: true,
});
```
## API Reference
- `createAuditPlugin(options: AuditPluginOptions): PluginDescriptor<AuditPluginConfig>`
- Validates `options` via `AuditPluginOptionsSchema`.
- Returns a descriptor with `name: "audit"`, `version: "0.1.0"`, resolved `defaults`, `onInit`, `onClose`.
## Implementation Notes
The factory captures the resolved `config` and a small `AuditPluginState` (currently just the cached logger) in a closure shared between `onInit` and `onClose`. The dev-default secret-key warning fires every time `onInit` runs — operators see it once per boot, which is the right cadence (loud enough to notice, quiet enough not to spam). The plugin does NOT depend on `@suaveplan/api`; it depends only on `@suaveplan/api-plugin` (for `definePlugin` + the context types) plus `@suaveplan/audit-logging` (for the runtime), `@suaveplan/error` / `@suaveplan/types`, `hono`, and `zod`. This keeps the dep graph small and lets the plugin attach to any Hono-based host.

View File

@@ -0,0 +1,70 @@
# redact/redact
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lightweight, allocation-free-ish payload redactor used by the audit middleware to scrub secret-bearing fields from per-request audit events. The middleware runs on every non-excluded request, so the redactor is on the hot path; it intentionally avoids pulling in `@suaveplan/data-masking`'s rule-engine machinery for the common case (named-field substitution). Operators that need format-preserving masking, tokenisation, or path-pattern matching call `maskObject` from `@suaveplan/data-masking` inside their handler-level `c.get("audit").log(...)` flows.
The default rule set covers the canonical secret-bearing names (`authorization`, `cookie`, `set-cookie`, `x-api-key` for headers; `token`, `code`, `state` for query; `password`, `secret`, `token` for body) and is composable: callers append their own field names per the plugin options.
## Features
- Case-insensitive header redaction (HTTP headers are case-insensitive per RFC 7230).
- Exact-match query and body redaction.
- Shallow clone of the payload — original objects are never mutated.
- Stable replacement marker (`"[REDACTED]"`).
- Defaults exported as a typed `RedactionRules` constant.
## Usage: Basic
```ts
import {
DEFAULT_REDACTION_RULES,
redactPayload,
} from "@suaveplan/api-plugin-audit/redact/redact";
const safe = redactPayload(
{
headers: { Authorization: "Bearer abc" },
query: { token: "xyz" },
body: { password: "p", email: "a@b" },
},
DEFAULT_REDACTION_RULES
);
// safe.headers.Authorization === "[REDACTED]"
// safe.query.token === "[REDACTED]"
// safe.body.password === "[REDACTED]"
// safe.body.email === "a@b"
```
## Usage: Advanced
```ts
import { redactPayload, REDACTED } from "@suaveplan/api-plugin-audit/redact/redact";
const orgRules = {
headers: ["authorization", "cookie", "x-internal-token"],
query: ["token", "session"],
body: ["password", "ssn", "credit_card"],
};
const safe = redactPayload(
{ headers: { "X-Internal-Token": "secret" } },
orgRules
);
console.assert(safe.headers?.["X-Internal-Token"] === REDACTED);
```
## API Reference
- `REDACTED: "[REDACTED]"` — substitution marker.
- `DEFAULT_REDACTION_RULES: RedactionRules` — frozen default lists.
- `redactPayload(payload: RedactablePayload, rules: RedactionRules): RedactablePayload` — returns a shallow-redacted clone. Header keys preserve original casing; only the comparison is case-insensitive.
## Implementation Notes
`Set` lookup is `O(1)`; header lookup builds a single lowercased set once and compares each header key lowercased. The function does not recurse — body objects are matched at the top level only. This is sufficient for the request-audit path (where the body is typically a flat JSON object); deep masking is the masking-rule engine's job and lives in `@suaveplan/data-masking`. The redactor is pure and synchronous so it can run inside the middleware's preflight without an `await`.

View File

@@ -0,0 +1,64 @@
# schemas/options
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schema that validates `AuditPluginOptions` at the `createAuditPlugin` factory boundary and produces the resolved `AuditPluginConfig` shape consumed by the lifecycle hooks. Centralises every default value the plugin ships with (sampling rate, exclude paths, redaction rules, dev-only secret key) so the rest of the code can rely on a fully-populated config object.
This is the single source of truth for option defaults; anything not here is either a runtime decision (sink injection, logger choice) or a per-request decision (sampling RNG draw).
## Features
- Zod 4 schema with `.transform(...)` returning a strongly-typed resolved config.
- Defaults for `samplingRate` (1), `excludePaths` (health probes + openapi doc), redaction lists (Authorization/Cookie/Token/Password/etc), `recordResponseSize` (true), `recordRequestBody` (false), `enableChainIntegrity` (false).
- `samplingRate` clamped to `[0, 1]`.
- Optional `sink`, `logger`, `rng` slots remain `unknown` here; the factory narrows them at the call site with concrete imports to avoid pulling `@suaveplan/audit-logging` into the schemas barrel as a value dep.
- Exposes `DEV_DEFAULT_SECRET_KEY` constant so callers and tests can inspect the fallback.
## Usage: Basic
```ts
import { AuditPluginOptionsSchema } from "@suaveplan/api-plugin-audit/schemas/options";
const config = AuditPluginOptionsSchema.parse({
secretKey: process.env.AUDIT_SECRET_KEY,
});
console.log(config.samplingRate); // 1
console.log(config.excludePaths); // ["/health", "/healthz", "/ready", "/openapi.json"]
```
## Usage: Advanced
```ts
import { AuditPluginOptionsSchema } from "@suaveplan/api-plugin-audit/schemas/options";
const result = AuditPluginOptionsSchema.safeParse({
samplingRate: 0.25,
redactBodyFields: ["password", "ssn", "credit_card"],
enableChainIntegrity: true,
secretKey: process.env.AUDIT_SECRET_KEY,
});
if (!result.success) {
// ZodError carrying every boundary violation
throw result.error;
}
const resolved = result.data;
```
## API Reference
- `AuditPluginOptionsSchema` — Zod schema returning `AuditPluginOptionsResolved`.
- `AuditPluginOptionsInput` — `z.input<typeof AuditPluginOptionsSchema>`.
- `AuditPluginOptionsResolved` — `z.output<typeof AuditPluginOptionsSchema>`.
- `DEFAULT_REDACT_HEADERS`, `DEFAULT_REDACT_QUERY`, `DEFAULT_REDACT_BODY`, `DEFAULT_EXCLUDE_PATHS` — `readonly string[]` defaults.
- `DEV_DEFAULT_SECRET_KEY` — string fallback used when `secretKey` is omitted.
## Implementation Notes
The schema keeps the `sink` and `logger` slots as `unknown` rather than typed Zod schemas because both interfaces are runtime objects and Zod cannot validate their shape without arbitrary `z.custom` predicates. The factory passes them straight through to `new AuditLogger({ ... })`, which already validates `secretKey` and the storage adapter contract. The `secretKey` default exists so the dev path works without env wiring; the factory MUST log a warn when the default is used so operators do not ship it to production.

View File

@@ -0,0 +1,72 @@
# services/audit-service
## Purpose
Constructs the `AuditService` adapter the plugin registers in `ServiceRegistry` under the `auditService` slot. The adapter wraps a constructed `AuditLogger` from `@suaveplan/audit-logging` so downstream code depends on the stable interface declared in `../types/types.ts` instead of binding to whatever the universal library exposes today. If the library renames or removes a method later, only this adapter file changes.
The wrapper is intentionally thin — every method delegates to the underlying logger one-for-one — but it gives the plugin a clean seam to add cross-cutting behaviour (tenancy enforcement, OTel correlation, idempotency) without touching every call site.
## Features
- 1:1 proxy over `AuditLogger.log / query / verifyChainIntegrity / exportGDPRData / applyRetentionPolicy`.
- `getLogger()` escape hatch for callers that need direct access (the underlying logger exposes operations not in the stable surface, e.g. event-bus introspection).
- Constructor takes a plain `{ logger }` dependency bundle — easy to swap or stub at test time.
## Usage: Basic
```ts
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { createAuditService } from "@suaveplan/api-plugin-audit/services/audit-service";
const logger = new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY!,
});
const auditService = createAuditService({ logger });
await auditService.log({
type: "user.created",
severity: "info",
actor: { userId: "u1" },
});
```
## Usage: Advanced
```ts
import {
AuditLogger,
MemoryAuditAdapter,
type AuditEvent,
} from "@suaveplan/audit-logging";
import { createAuditService } from "@suaveplan/api-plugin-audit/services/audit-service";
const auditService = createAuditService({
logger: new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY!,
enableChainIntegrity: true,
}),
});
const events: AuditEvent[] = await auditService.query({
userId: "u1",
limit: 50,
order: "desc",
});
const verdict = await auditService.verifyChainIntegrity(events);
if (!verdict.valid) {
console.error("chain broken at", verdict.brokenAt, verdict.reason);
}
```
## API Reference
- `createAuditService(deps: { logger: AuditLogger }): AuditService`
- Returns an object satisfying the `AuditService` interface from `../types/types.ts`.
- Methods: `getLogger()`, `log(event)`, `query(opts)`, `verifyChainIntegrity(events)`, `exportGDPRData(userId, signal?)`, `applyRetentionPolicy(signal?)`.
## Implementation Notes
Every method returns the underlying `Promise` directly — no wrapping, no try/catch — because the plugin's middleware path handles sink failures explicitly via `audit.sink_failures_total`. Consumers that call `auditService.log(...)` from a handler get the raw `AuditError` from `@suaveplan/audit-logging` and can branch on its codes. This separation keeps the middleware (which must never throw into the request response) and the handler-level helper (which can surface failures) consistent.

View File

@@ -0,0 +1,52 @@
# services/registry-augmentation
## Purpose
Declares the `auditService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript module augmentation. Importing this module for its side effects (which the package root barrel does automatically) is sufficient — every consumer of `@suaveplan/api-plugin-audit` gets typed `ctx.getService("auditService")` access with the return type narrowed to `AuditService | undefined`, no casts.
The file also cumulatively re-declares the host-side `app` slot so the audit plugin's `onInit(ctx)` resolves `ctx.getService("app")` to `Hono` even when the consumer has not explicitly imported `@suaveplan/api`'s registry-augmentation. TypeScript merges duplicate declarations across modules, so the cumulative augmentation is safe.
## Features
- TypeScript module augmentation — zero runtime cost.
- Cumulative — does not collide with `@suaveplan/api-plugin-auth`'s or `@suaveplan/api`'s own augmentations.
- Side-effect imported by the package barrel so consumers do not need an explicit subpath import.
## Usage: Basic
```ts
// In your app entry:
import "@suaveplan/api-plugin-audit"; // side-effect: registers ServiceRegistry slot
// In a sibling plugin's onInit:
const audit = ctx.getService("auditService"); // typed as AuditService | undefined
await audit?.log({
type: "user.created",
severity: "info",
actor: { userId: "u1" },
});
```
## Usage: Advanced
```ts
// In a custom test harness that constructs PluginContext directly:
import "@suaveplan/api-plugin-audit/services/audit-service"; // also surfaces the augmentation
import { createPluginContext } from "@suaveplan/api-plugin";
const ctx = createPluginContext({
/* ... */
});
const audit = ctx.getService("auditService"); // AuditService | undefined
```
## API Reference
This file has no runtime exports; it contributes a single TypeScript `declare module "@suaveplan/api-plugin/service-registry"` block adding:
- `auditService: AuditService`
- `app: Hono`
## Implementation Notes
The augmentation must live in a module that is reachable from the consumer's TypeScript program (either via a direct import or via a transitive dependency that pulls it in). Importing the package barrel of `@suaveplan/api-plugin-audit` covers the common case. If the augmentation is missing at compile time, `ctx.getService("auditService")` types as `never` per the `ServiceRegistry` defaults and fails the build with `Argument of type '"auditService"' is not assignable…`. That failure is the intended UX — it tells the operator the plugin barrel was not loaded.

View File

@@ -0,0 +1,65 @@
# types/types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Central, runtime-free declaration of the interfaces the plugin's lifecycle, middleware, services, and context helpers depend on. Pulling these out of the implementation files lets the package barrel re-export the public type surface without dragging implementation modules into the type graph, and lets sibling packages (e.g. `@suaveplan/api-plugin-vault`) refer to `AuditService` without taking a runtime dep on this package's implementation files.
Everything here is structural: `AuditPluginOptions` is the caller-supplied shape (validated by `AuditPluginOptionsSchema`); `AuditPluginConfig` is the resolved shape consumed by the middleware; `AuditService` is the ServiceRegistry surface; `AuditContext` is the per-request handler-level helper. `RedactablePayload` and `RedactionRules` are the redactor's input contracts.
## Features
- Zero runtime — every export is `interface` / `type`.
- `AuditPluginOptions` makes every field optional (defaults are applied by the schema).
- `AuditPluginConfig` is the fully-resolved post-validation shape; `sink`, `secretKey`, sampling, redaction lists, booleans, and the RNG are all required.
- `AuditService` mirrors the methods consumers can call on the registered slot.
- `AuditContext` is intentionally minimal (`log` + `getLogger`) so handlers stay focused.
## Usage: Basic
```ts
import type {
AuditPluginOptions,
AuditService,
} from "@suaveplan/api-plugin-audit/types/types";
const options: AuditPluginOptions = { secretKey: process.env.AUDIT_SECRET_KEY };
function withAuditService(svc: AuditService) {
return svc.log({ type: "thing.created", severity: "info", actor: {} });
}
```
## Usage: Advanced
```ts
import type {
AuditPluginConfig,
RedactionRules,
} from "@suaveplan/api-plugin-audit/types/types";
const rules: RedactionRules = {
headers: ["authorization", "x-internal-key"],
query: ["token", "session"],
body: ["password", "ssn"],
};
function tweakSamplingRate(cfg: AuditPluginConfig, rate: number): AuditPluginConfig {
return { ...cfg, samplingRate: rate };
}
```
## API Reference
- `AuditPluginOptions` — caller-supplied shape; every field optional.
- `AuditPluginConfig` — resolved shape; required fields are populated by `AuditPluginOptionsSchema`.
- `AuditService` — 6-method surface registered as `auditService` in `ServiceRegistry`.
- `AuditContext` — 2-method per-request helper attached to `c.set("audit", ...)`.
- `RedactablePayload` — `{ query?, headers?, body? }`.
- `RedactionRules` — `{ query: readonly string[]; headers: readonly string[]; body: readonly string[] }`.
## Implementation Notes
The schema file (`schemas/options.ts`) exposes `AuditPluginOptionsInput` / `AuditPluginOptionsResolved` derived from the Zod schema; the interfaces here are the canonical TypeScript shapes the rest of the package consumes. The two are kept in sync by hand because Zod's `infer` on a `.transform(...)` schema produces a less-friendly type than the hand-written interface. The narrowing (and any drift) is exercised by `src/schemas/options.test.ts`.