Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,118 @@
{
"name": "@suaveplan/api-plugin-audit",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./middleware/audit-middleware": {
"import": {
"types": "./dist/middleware/audit-middleware.d.ts",
"default": "./dist/middleware/audit-middleware.js"
}
},
"./services/audit-service": {
"import": {
"types": "./dist/services/audit-service.d.ts",
"default": "./dist/services/audit-service.js"
}
},
"./context/audit-context": {
"import": {
"types": "./dist/context/audit-context.d.ts",
"default": "./dist/context/audit-context.js"
}
},
"./redact/redact": {
"import": {
"types": "./dist/redact/redact.d.ts",
"default": "./dist/redact/redact.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/audit-logging": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/events": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,43 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIT_PLUGIN_CODES } from "./error-codes/error-codes.js";
const SRC = join(__dirname);
const SRC_FILES_CONTAINING_RAISES: readonly string[] = [
"plugin/plugin.ts",
"middleware/audit-middleware.ts",
];
function readSrc(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
describe("api-plugin-audit spec conformance", () => {
it("every AUDIT_PLUGIN_* code is thrown from a production path", {
timeout: 5000,
}, () => {
const codes = Object.values(AUDIT_PLUGIN_CODES);
const corpus = SRC_FILES_CONTAINING_RAISES.map(readSrc).join("\n");
for (const code of codes) {
// Each code MUST appear in a `new AuditPluginError(...)` call
// site OR be produced via `AUDIT_PLUGIN_CODES.<NAME>`.
const short = code.replace(/^AUDIT_PLUGIN\./, "");
expect(
corpus.includes(`AUDIT_PLUGIN_CODES.${short}`),
`code ${code} not raised in any production source file`
).toBe(true);
}
});
it("registers exactly four error codes", { timeout: 5000 }, () => {
const codes = Object.keys(AUDIT_PLUGIN_CODES).sort();
expect(codes).toEqual([
"INVALID_INPUT",
"SERVICE_NOT_AVAILABLE",
"SINK_INIT_FAILED",
"SINK_WRITE_FAILED",
]);
});
});

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,37 @@
import {
AuditLogger,
AuditSeverity,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createAuditContext } from "./audit-context.js";
describe("createAuditContext", () => {
it("log writes through to the underlying AuditLogger", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
const ctx = createAuditContext({ logger });
await ctx.log({
type: "thing.created",
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
});
expect(storage.snapshot()).toHaveLength(1);
});
it("getLogger returns the underlying AuditLogger instance", {
timeout: 5000,
}, () => {
const logger = new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
});
const ctx = createAuditContext({ logger });
expect(ctx.getLogger()).toBe(logger);
});
});

View File

@@ -0,0 +1,27 @@
import type { AuditEvent, AuditLogger } from "@suaveplan/audit-logging";
import type { AuditContext } from "../types/types.js";
/**
* Creates an `AuditContext` bound to the given underlying logger.
* The middleware sets this on `c.set("audit", context)` before
* calling `next()` so handlers can fire explicit, semantic events
* alongside the implicit per-request audit.
*/
export function createAuditContext(deps: {
logger: AuditLogger;
}): AuditContext {
const { logger } = deps;
return {
getLogger(): AuditLogger {
return logger;
},
log(
event: Omit<
AuditEvent,
"id" | "timestamp" | "hash" | "previousHash"
>
): Promise<AuditEvent> {
return logger.log(event);
},
};
}

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,20 @@
/**
* Hono `ContextVariableMap` augmentation — declares the `audit` slot
* populated by the per-request audit middleware. Handlers read
* `c.get("audit")` typed as `AuditContext` thanks to this declaration;
* without it the value would type as `unknown`.
*
* The `user`, `tenant`, and `requestId` slots are declared as
* `unknown`-tolerant optionals because this package consumes them
* (read-only) without owning their precise shape. The auth plugin
* narrows `user` to `Principal | null`; the tenant plugin narrows
* `tenant`. TypeScript merges duplicate declarations, so coexistence
* with those plugins is safe.
*/
import type { AuditContext } from "../types/types.js";
declare module "hono" {
interface ContextVariableMap {
audit: AuditContext;
}
}

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,46 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-audit`.
*
* - `INVALID_INPUT` — `createAuditPlugin(options)` received options that
* failed `AuditPluginOptionsSchema` validation. `cause` holds the
* `ZodError`.
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot (e.g.
* `app`) was absent during `onInit`. `context.missing` lists the
* missing slot names.
* - `SINK_INIT_FAILED` — constructing the underlying `AuditLogger`
* threw (e.g. missing secret key when chain-integrity is enabled).
* - `SINK_WRITE_FAILED` — the configured sink rejected `.save(...)` from
* inside the per-request middleware. The middleware never propagates
* this to the request response; the error is reported through this
* code via the logger and the `audit.sink_failures_total` counter.
*/
export const AUDIT_PLUGIN_CODES = defineCodes("AUDIT_PLUGIN", [
"INVALID_INPUT",
"SERVICE_NOT_AVAILABLE",
"SINK_INIT_FAILED",
"SINK_WRITE_FAILED",
] as const);
export type AuditPluginErrorCode =
(typeof AUDIT_PLUGIN_CODES)[keyof typeof AUDIT_PLUGIN_CODES];
/**
* Concrete error class. Extends {@link SuaveplanError} so
* `instanceof AuditPluginError` narrows `code` to
* {@link AuditPluginErrorCode}.
*/
export class AuditPluginError extends SuaveplanError<AuditPluginErrorCode> {
public readonly auditPluginCode: AuditPluginErrorCode;
public constructor(
code: AuditPluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.auditPluginCode = code;
}
}

View File

@@ -0,0 +1,51 @@
// Side-effect imports — surface the registry + context augmentations
// to every consumer importing the package barrel.
import "./services/registry-augmentation.js";
import "./context/context-augmentation.js";
// Context
export { createAuditContext } from "./context/audit-context.js";
// Error codes
export {
AUDIT_PLUGIN_CODES,
AuditPluginError,
type AuditPluginErrorCode,
} from "./error-codes/error-codes.js";
// Middleware
export { createAuditMiddleware } from "./middleware/audit-middleware.js";
// OTel
export {
type AuditPluginInstruments,
getAuditPluginInstruments,
resetAuditPluginInstruments,
} from "./otel/instruments.js";
// Plugin factory
export { createAuditPlugin } from "./plugin/plugin.js";
// Redaction
export {
DEFAULT_REDACTION_RULES,
REDACTED,
redactPayload,
} from "./redact/redact.js";
// Schemas
export {
type AuditPluginOptionsInput,
type AuditPluginOptionsResolved,
AuditPluginOptionsSchema,
DEFAULT_EXCLUDE_PATHS,
DEFAULT_REDACT_BODY,
DEFAULT_REDACT_HEADERS,
DEFAULT_REDACT_QUERY,
DEV_DEFAULT_SECRET_KEY,
} from "./schemas/options.js";
// Service
export { createAuditService } from "./services/audit-service.js";
// Types
export type {
AuditContext,
AuditPluginConfig,
AuditPluginOptions,
AuditService,
RedactablePayload,
RedactionRules,
} from "./types/types.js";

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,573 @@
import {
type AuditEvent,
AuditLogger,
AuditSeverity,
type AuditStorageAdapter,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { resetAuditPluginInstruments } from "../otel/instruments.js";
import type { AuditPluginConfig } from "../types/types.js";
import { createAuditMiddleware } from "./audit-middleware.js";
const noopHostLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeConfig(
overrides: Partial<AuditPluginConfig> = {},
sink: AuditStorageAdapter = new MemoryAuditAdapter()
): AuditPluginConfig {
return {
sink,
secretKey: "x".repeat(32),
samplingRate: 1,
excludePaths: ["/health"],
redactQueryParams: ["token"],
redactHeaders: ["authorization"],
redactBodyFields: ["password"],
recordResponseSize: true,
recordRequestBody: false,
enableChainIntegrity: false,
rng: () => 0,
...overrides,
};
}
function makeApp(
cfg: AuditPluginConfig,
storage: AuditStorageAdapter,
setupRoutes: (app: Hono) => void
): Hono {
const logger = new AuditLogger({
storage,
secretKey: cfg.secretKey,
enableChainIntegrity: cfg.enableChainIntegrity,
});
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: { ...cfg, sink: storage },
hostLogger: noopHostLogger,
})
);
setupRoutes(app);
return app;
}
afterEach(() => {
resetAuditPluginInstruments();
});
describe("createAuditMiddleware", () => {
it("records a single event per request with principal and tenant", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.use("*", async (c, next) => {
c.set("user", { id: "u1" });
c.set("tenant", { id: "t1" });
await next();
});
a.get("/things", (c) => c.json({ ok: true }));
});
const res = await app.request("/things");
expect(res.status).toBe(200);
const events = storage.snapshot();
expect(events).toHaveLength(1);
const evt = events[0] as AuditEvent;
expect(evt.actor.userId).toBe("u1");
expect(evt.tenantId).toBe("t1");
expect((evt.details as { method: string }).method).toBe("GET");
expect((evt.details as { path: string }).path).toBe("/things");
expect((evt.details as { status: number }).status).toBe(200);
expect(
typeof (evt.details as { duration_ms: number }).duration_ms
).toBe("number");
expect(evt.severity).toBe(AuditSeverity.INFO);
});
it("redacts sensitive query headers and body", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({ recordRequestBody: true }, storage);
const app = makeApp(cfg, storage, (a) => {
a.post("/login", async (c) => c.json({ ok: true }));
});
const res = await app.request("/login?token=abc&safe=v", {
method: "POST",
headers: {
Authorization: "Bearer secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ password: "x", email: "a@b" }),
});
expect(res.status).toBe(200);
const evt = storage.snapshot()[0] as AuditEvent;
const det = evt.details as {
headers: Record<string, string>;
query: Record<string, string>;
body?: Record<string, unknown>;
};
expect(det.headers["authorization"]).toBe("[REDACTED]");
expect(det.query["token"]).toBe("[REDACTED]");
expect(det.query["safe"]).toBe("v");
expect(det.body?.["password"]).toBe("[REDACTED]");
expect(det.body?.["email"]).toBe("a@b");
});
it("excludePaths skip recording", { timeout: 5000 }, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/health", (c) => c.text("ok"));
});
await app.request("/health");
expect(storage.snapshot()).toHaveLength(0);
});
it("never throws when sink fails", { timeout: 5000 }, async () => {
const failingSink: AuditStorageAdapter = {
save: async () => {
throw new Error("sink down");
},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
let warnSeen = false;
const hostLogger = {
...noopHostLogger,
warn: () => {
warnSeen = true;
},
};
const logger = new AuditLogger({
storage: failingSink,
secretKey: "x".repeat(32),
});
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: makeConfig({}, failingSink),
hostLogger,
})
);
app.get("/things", (c) => c.json({ ok: true }));
const res = await app.request("/things");
expect(res.status).toBe(200);
expect(warnSeen).toBe(true);
});
it("severity classification by status", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/notfound", (c) => c.text("missing", 404));
a.get("/boom", (c) => c.text("err", 500));
});
await app.request("/notfound");
await app.request("/boom");
const evts = storage.snapshot();
const not = evts.find(
(e) => (e.details as { path: string }).path === "/notfound"
) as AuditEvent;
const boom = evts.find(
(e) => (e.details as { path: string }).path === "/boom"
) as AuditEvent;
expect(not.severity).toBe(AuditSeverity.WARNING);
expect(boom.severity).toBe(AuditSeverity.ERROR);
});
it("sampling miss skips recording", { timeout: 5000 }, async () => {
const storage = new MemoryAuditAdapter();
// rng() returns 0.9; samplingRate 0.5 → miss
const cfg = makeConfig({ samplingRate: 0.5, rng: () => 0.9 }, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x");
expect(storage.snapshot()).toHaveLength(0);
});
it("installs c.get(audit) before next()", { timeout: 5000 }, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
let seenAudit: unknown;
const app = makeApp(cfg, storage, (a) => {
a.get("/check", (c) => {
seenAudit = c.get("audit");
return c.text("ok");
});
});
await app.request("/check");
expect(seenAudit).toBeDefined();
const audit = seenAudit as { log: unknown; getLogger: unknown };
expect(typeof audit.log).toBe("function");
expect(typeof audit.getLogger).toBe("function");
});
it("captures request id from x-request-id header", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x", {
headers: { "x-request-id": "req-123" },
});
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { request_id: string }).request_id).toBe(
"req-123"
);
expect(evt.correlationId).toBe("req-123");
});
it("captures request id from context when header absent", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.use("*", async (c, next) => {
c.set("requestId", "ctx-456");
await next();
});
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x");
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { request_id: string }).request_id).toBe(
"ctx-456"
);
});
it("generates a request id when neither header nor context is set", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x");
const evt = storage.snapshot()[0] as AuditEvent;
expect(
(evt.details as { request_id: string }).request_id.length
).toBeGreaterThan(0);
});
it("records response size from content-length header", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/x", (c) => {
const body = "hello-world";
return c.body(body, 200, {
"Content-Length": String(body.length),
});
});
});
await app.request("/x");
const evt = storage.snapshot()[0] as AuditEvent;
const sz = (evt.details as { response_size?: number }).response_size;
expect(typeof sz).toBe("number");
expect(sz).toBe(11);
});
it("omits response_size when Content-Length header is absent", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x");
const evt = storage.snapshot()[0] as AuditEvent;
expect(
(evt.details as { response_size?: number }).response_size
).toBeUndefined();
});
it("defaults to status 200 when no response was assigned", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const logger = new AuditLogger({
storage,
secretKey: cfg.secretKey,
});
const middleware = createAuditMiddleware({
logger,
config: { ...cfg, sink: storage },
hostLogger: noopHostLogger,
});
const fakeCtx = {
req: {
url: "http://x/x",
method: "GET",
raw: new Request("http://x/x"),
header: () => undefined,
},
res: undefined,
_store: new Map<string, unknown>(),
set(this: { _store: Map<string, unknown> }, k: string, v: unknown) {
this._store.set(k, v);
},
get(this: { _store: Map<string, unknown> }, k: string) {
return this._store.get(k);
},
} as unknown as Parameters<typeof middleware>[0];
await middleware(fakeCtx, async () => undefined);
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { status: number }).status).toBe(200);
});
it("treats non-Error sink rejection as cause-less", {
timeout: 5000,
}, async () => {
// Use a real AuditLogger but override `log` so a non-Error throw
// reaches the middleware's catch unwrapped.
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
(logger as unknown as { log: () => Promise<never> }).log = async () => {
const lit: unknown = "literal-string-rejection";
throw lit;
};
let warnSeen = false;
const hostLogger = {
...noopHostLogger,
warn: () => {
warnSeen = true;
},
};
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: makeConfig({}, storage),
hostLogger,
})
);
app.get("/x", (c) => c.text("ok"));
const res = await app.request("/x");
expect(res.status).toBe(200);
expect(warnSeen).toBe(true);
});
it("re-throws handler errors after recording", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const logger = new AuditLogger({
storage,
secretKey: cfg.secretKey,
});
// Invoke the middleware directly so the throw bubbles into
// the audit middleware's try/catch (Hono's compose.js
// intercepts errors before they reach app.fetch's caller).
const middleware = createAuditMiddleware({
logger,
config: { ...cfg, sink: storage },
hostLogger: noopHostLogger,
});
const fakeCtx = {
req: {
url: "http://x/boom",
method: "GET",
raw: new Request("http://x/boom"),
header: () => undefined,
},
res: { status: 200, headers: new Headers() },
// biome-safe shim — we only need set/get for the audit context
// and requestId slots.
_store: new Map<string, unknown>(),
set(this: { _store: Map<string, unknown> }, k: string, v: unknown) {
this._store.set(k, v);
},
get(this: { _store: Map<string, unknown> }, k: string) {
return this._store.get(k);
},
} as unknown as Parameters<typeof middleware>[0];
let thrown: unknown;
try {
await middleware(fakeCtx, async () => {
throw new Error("chain-fail");
});
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toBe("chain-fail");
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { status: number }).status).toBe(500);
expect(evt.severity).toBe(AuditSeverity.ERROR);
});
it("ignores body capture when content-type is not JSON", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({ recordRequestBody: true }, storage);
const app = makeApp(cfg, storage, (a) => {
a.post("/x", (c) => c.text("ok"));
});
await app.request("/x", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "raw-body",
});
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { body?: unknown }).body).toBeUndefined();
});
it("ignores malformed JSON body", { timeout: 5000 }, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({ recordRequestBody: true }, storage);
const app = makeApp(cfg, storage, (a) => {
a.post("/x", (c) => c.text("ok"));
});
await app.request("/x", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not-json",
});
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { body?: unknown }).body).toBeUndefined();
});
it("ignores non-object JSON body", { timeout: 5000 }, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({ recordRequestBody: true }, storage);
const app = makeApp(cfg, storage, (a) => {
a.post("/x", (c) => c.text("ok"));
});
await app.request("/x", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify("plain-string"),
});
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { body?: unknown }).body).toBeUndefined();
});
it("uses default redaction rules when caller passes empty lists", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig(
{
redactQueryParams: [],
redactHeaders: [],
redactBodyFields: [],
recordRequestBody: true,
},
storage
);
const app = makeApp(cfg, storage, (a) => {
a.post("/x", (c) => c.json({ ok: true }));
});
await app.request("/x?token=t", {
method: "POST",
headers: {
Authorization: "Bearer s",
"Content-Type": "application/json",
},
body: JSON.stringify({ password: "p" }),
});
const evt = storage.snapshot()[0] as AuditEvent;
const det = evt.details as {
headers: Record<string, string>;
query: Record<string, string>;
body?: Record<string, unknown>;
};
expect(det.query["token"]).toBe("[REDACTED]");
expect(det.headers["authorization"]).toBe("[REDACTED]");
expect(det.body?.["password"]).toBe("[REDACTED]");
});
it("treats empty x-request-id header as absent", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.use("*", async (c, next) => {
c.set("requestId", "ctx-id");
await next();
});
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x", { headers: { "x-request-id": "" } });
const evt = storage.snapshot()[0] as AuditEvent;
expect((evt.details as { request_id: string }).request_id).toBe(
"ctx-id"
);
});
it("treats empty context requestId as absent", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({}, storage);
const app = makeApp(cfg, storage, (a) => {
a.use("*", async (c, next) => {
c.set("requestId", "");
await next();
});
a.get("/x", (c) => c.text("ok"));
});
await app.request("/x");
const evt = storage.snapshot()[0] as AuditEvent;
expect(
(evt.details as { request_id: string }).request_id.length
).toBeGreaterThan(0);
});
it("skips response_size when recordResponseSize is false", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const cfg = makeConfig({ recordResponseSize: false }, storage);
const app = makeApp(cfg, storage, (a) => {
a.get("/x", (c) => c.json({ ok: true }));
});
await app.request("/x");
const evt = storage.snapshot()[0] as AuditEvent;
expect(
(evt.details as { response_size?: number }).response_size
).toBeUndefined();
});
});

View File

@@ -0,0 +1,241 @@
import { type AuditLogger, AuditSeverity } from "@suaveplan/audit-logging";
import type { LoggerLike } from "@suaveplan/types";
import type { Context, MiddlewareHandler } from "hono";
import { createAuditContext } from "../context/audit-context.js";
import {
AUDIT_PLUGIN_CODES,
AuditPluginError,
} from "../error-codes/error-codes.js";
import { getAuditPluginInstruments } from "../otel/instruments.js";
import { DEFAULT_REDACTION_RULES, redactPayload } from "../redact/redact.js";
import type {
AuditPluginConfig,
RedactablePayload,
RedactionRules,
} from "../types/types.js";
interface AuditMiddlewareDeps {
readonly logger: AuditLogger;
readonly config: AuditPluginConfig;
readonly hostLogger: LoggerLike;
}
interface PrincipalLike {
id?: string;
}
interface TenantLike {
id?: string;
}
function readPrincipalId(c: Context): string | undefined {
const user = c.get("user") as PrincipalLike | null | undefined;
return user?.id;
}
function readTenantId(c: Context): string | undefined {
const tenant = c.get("tenant") as TenantLike | null | undefined;
return tenant?.id;
}
function readRequestId(c: Context): string {
const headerId = c.req.header("x-request-id");
if (typeof headerId === "string" && headerId.length > 0) return headerId;
const ctxId = c.get("requestId") as string | undefined;
if (typeof ctxId === "string" && ctxId.length > 0) return ctxId;
return crypto.randomUUID();
}
function classifySeverity(status: number): AuditSeverity {
if (status < 400) return AuditSeverity.INFO;
if (status < 500) return AuditSeverity.WARNING;
return AuditSeverity.ERROR;
}
function readQuery(c: Context): Record<string, string> {
const out: Record<string, string> = {};
const url = new URL(c.req.url);
for (const [k, v] of url.searchParams.entries()) {
out[k] = v;
}
return out;
}
function readHeaders(c: Context): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of c.req.raw.headers.entries()) {
out[k] = v;
}
return out;
}
async function readBodyIfJson(
c: Context
): Promise<Record<string, unknown> | undefined> {
const ct = c.req.header("content-type");
if (ct === undefined || !ct.toLowerCase().includes("application/json")) {
return undefined;
}
try {
const cloned = c.req.raw.clone();
const parsed = (await cloned.json()) as unknown;
if (parsed !== null && typeof parsed === "object") {
return parsed as Record<string, unknown>;
}
return undefined;
} catch {
return undefined;
}
}
function buildRules(config: AuditPluginConfig): RedactionRules {
return {
query:
config.redactQueryParams.length > 0
? config.redactQueryParams
: DEFAULT_REDACTION_RULES.query,
headers:
config.redactHeaders.length > 0
? config.redactHeaders
: DEFAULT_REDACTION_RULES.headers,
body:
config.redactBodyFields.length > 0
? config.redactBodyFields
: DEFAULT_REDACTION_RULES.body,
};
}
/**
* Creates the global per-request audit middleware. Records one
* `AuditEvent` per non-excluded request and exposes the `audit` slot
* on the Hono context so handlers can fire explicit events.
*
* Behaviour contract:
*
* - NEVER throws into the request response path — sink failures are
* logged at warn and counted via `audit.sink_failures_total`.
* - Honors `excludePaths` (exact match) and `samplingRate` (head-end
* gate via the supplied `rng`).
* - Sets `c.set("audit", createAuditContext({ logger }))` BEFORE
* awaiting `next()` so handlers can call `c.get("audit").log(...)`
* regardless of whether the request itself is recorded.
*/
export function createAuditMiddleware(
deps: AuditMiddlewareDeps
): MiddlewareHandler {
const { logger, config, hostLogger } = deps;
const rules = buildRules(config);
const instruments = getAuditPluginInstruments();
const excludeSet = new Set(config.excludePaths);
const auditContext = createAuditContext({ logger });
return async (c, next) => {
c.set("audit", auditContext);
const path = new URL(c.req.url).pathname;
if (excludeSet.has(path)) {
instruments.requestsExcluded.add(1, { path });
await next();
return;
}
const sampled = config.rng() < config.samplingRate;
if (!sampled) {
await next();
instruments.requestsRecorded.add(1, {
outcome: "success",
sampled: "false",
});
return;
}
const t0 = Date.now();
const method = c.req.method;
const rawQuery = readQuery(c);
const rawHeaders = readHeaders(c);
const rawBody = config.recordRequestBody
? await readBodyIfJson(c)
: undefined;
const preflightPayload: RedactablePayload = {
query: rawQuery,
headers: rawHeaders,
...(rawBody !== undefined ? { body: rawBody } : {}),
};
const redacted = redactPayload(preflightPayload, rules);
let nextErr: unknown;
try {
await next();
} catch (err) {
nextErr = err;
}
const status = nextErr !== undefined ? 500 : (c.res?.status ?? 200);
const durationMs = Date.now() - t0;
const requestId = readRequestId(c);
const principalId = readPrincipalId(c);
const tenantId = readTenantId(c);
const contentLengthRaw = c.res?.headers.get("content-length");
const responseSize =
config.recordResponseSize && contentLengthRaw !== null
? Number(contentLengthRaw)
: undefined;
const details: Record<string, unknown> = {
method,
path,
status,
duration_ms: durationMs,
request_id: requestId,
query: redacted.query,
headers: redacted.headers,
};
if (redacted.body !== undefined) details["body"] = redacted.body;
if (responseSize !== undefined && !Number.isNaN(responseSize)) {
details["response_size"] = responseSize;
}
try {
await logger.log({
type: `http.${method.toLowerCase()}`,
severity: classifySeverity(status),
actor: {
...(principalId !== undefined
? { userId: principalId }
: {}),
...(tenantId !== undefined ? { tenantId } : {}),
},
...(tenantId !== undefined ? { tenantId } : {}),
correlationId: requestId,
details,
});
instruments.requestsRecorded.add(1, {
outcome: "success",
sampled: "true",
});
} catch (sinkErr) {
instruments.sinkFailures.add(1);
const err = new AuditPluginError(
AUDIT_PLUGIN_CODES.SINK_WRITE_FAILED,
"audit sink rejected save()",
{
...(sinkErr instanceof Error ? { cause: sinkErr } : {}),
context: { requestId, status },
}
);
hostLogger.warn(err.message, {
code: err.code,
requestId,
status,
});
instruments.requestsRecorded.add(1, {
outcome: "error",
sampled: "true",
});
} finally {
instruments.middlewareDuration.record(Date.now() - t0);
}
if (nextErr !== undefined) throw nextErr;
};
}

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,212 @@
import {
AuditLogger,
type AuditStorageAdapter,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { createAuditMiddleware } from "../middleware/audit-middleware.js";
import type { AuditPluginConfig } from "../types/types.js";
import {
getAuditPluginInstruments,
resetAuditPluginInstruments,
} from "./instruments.js";
const noopHostLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeConfig(
overrides: Partial<AuditPluginConfig> = {}
): AuditPluginConfig {
return {
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
samplingRate: 1,
excludePaths: ["/health"],
redactQueryParams: [],
redactHeaders: [],
redactBodyFields: [],
recordResponseSize: true,
recordRequestBody: false,
enableChainIntegrity: false,
rng: () => 0,
...overrides,
};
}
afterEach(() => {
resetAuditPluginInstruments();
});
function collectCounterTotal(
metrics: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
): number {
let total = 0;
for (const rm of metrics) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) {
for (const dp of m.dataPoints) {
total += Number(dp.value);
}
}
}
}
}
return total;
}
function hasMetric(
metrics: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
): boolean {
for (const rm of metrics) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) return true;
}
}
}
return false;
}
describe("otel/instruments", () => {
it("getAuditPluginInstruments caches the instrument bundle", {
timeout: 5000,
}, () => {
const a = getAuditPluginInstruments();
const b = getAuditPluginInstruments();
expect(a).toBe(b);
});
it("counter and histogram emit per recorded request", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: makeConfig({ sink: storage }),
hostLogger: noopHostLogger,
})
);
app.get("/x", (c) => c.text("ok"));
await app.request("/x");
const m = await flushTestMetrics();
expect(
collectCounterTotal(m, "audit.requests_recorded_total")
).toBeGreaterThan(0);
expect(hasMetric(m, "audit.middleware_duration_ms")).toBe(true);
});
it("excluded counter increments for health checks", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: makeConfig({ sink: storage }),
hostLogger: noopHostLogger,
})
);
app.get("/health", (c) => c.text("ok"));
await app.request("/health");
const m = await flushTestMetrics();
expect(
collectCounterTotal(m, "audit.requests_excluded_total")
).toBeGreaterThan(0);
});
it("sink failures counter increments on sink rejection", {
timeout: 5000,
}, async () => {
const failingSink: AuditStorageAdapter = {
save: async () => {
throw new Error("down");
},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = new AuditLogger({
storage: failingSink,
secretKey: "x".repeat(32),
});
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: makeConfig({ sink: failingSink }),
hostLogger: noopHostLogger,
})
);
app.get("/x", (c) => c.text("ok"));
await app.request("/x");
const m = await flushTestMetrics();
expect(
collectCounterTotal(m, "audit.sink_failures_total")
).toBeGreaterThan(0);
});
it("resetAuditPluginInstruments drops the cache", {
timeout: 5000,
}, () => {
const a = getAuditPluginInstruments();
resetAuditPluginInstruments();
const b = getAuditPluginInstruments();
expect(a).not.toBe(b);
});
it("sampled=false counter increments when rng misses", {
timeout: 5000,
}, async () => {
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config: makeConfig({
sink: storage,
samplingRate: 0.5,
rng: () => 0.9,
}),
hostLogger: noopHostLogger,
})
);
app.get("/x", (c) => c.text("ok"));
await app.request("/x");
const m = await flushTestMetrics();
expect(
collectCounterTotal(m, "audit.requests_recorded_total")
).toBeGreaterThan(0);
// and no event saved
expect(storage.snapshot()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,59 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* OpenTelemetry instruments owned by `@suaveplan/api-plugin-audit`.
*
* The instruments are resolved lazily on first access so test
* harnesses that install a custom `MeterProvider` BEFORE importing
* any production module (see `tests/setup/polyfills.ts`) capture
* every observation.
*/
export interface AuditPluginInstruments {
readonly meter: Meter;
readonly requestsRecorded: Counter;
readonly requestsExcluded: Counter;
readonly sinkFailures: Counter;
readonly middlewareDuration: Histogram;
}
let cached: AuditPluginInstruments | undefined;
/** Returns the cached instrument bundle, creating it on first call. */
export function getAuditPluginInstruments(): AuditPluginInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-audit");
cached = {
meter,
requestsRecorded: meter.createCounter("audit.requests_recorded_total", {
description:
"Audit events recorded per request, labelled by outcome and sampled flag.",
}),
requestsExcluded: meter.createCounter("audit.requests_excluded_total", {
description:
"Requests that matched `excludePaths` and were skipped by the audit middleware.",
}),
sinkFailures: meter.createCounter("audit.sink_failures_total", {
description:
"Sink rejections caught by the audit middleware (request response is unaffected).",
}),
middlewareDuration: meter.createHistogram(
"audit.middleware_duration_ms",
{
description:
"Wall-clock duration of the audit middleware (preflight + sink write) in milliseconds.",
unit: "ms",
}
),
};
return cached;
}
/**
* Test-only — drop the cached instrument bundle so the next access
* binds to whichever `MeterProvider` is currently global. Not part
* of the runtime contract.
*/
export function resetAuditPluginInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,164 @@
import { createPluginContext } from "@suaveplan/api-plugin";
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { EventBus } from "@suaveplan/events";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
AUDIT_PLUGIN_CODES,
type AuditPluginError,
} from "../error-codes/error-codes.js";
import type { AuditPluginConfig } from "../types/types.js";
import { __setAuditLoggerFactory, createAuditPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeCtx(services: Map<string, unknown>, config: AuditPluginConfig) {
return createPluginContext<AuditPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config,
services,
});
}
afterEach(() => {
__setAuditLoggerFactory(undefined);
});
describe("onInit", () => {
it("raises AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE when app missing", {
timeout: 5000,
}, async () => {
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
});
const services = new Map<string, unknown>();
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
let caught: AuditPluginError | undefined;
try {
await plugin.onInit?.(ctx);
} catch (err) {
caught = err as AuditPluginError;
}
expect(caught?.code).toBe(AUDIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE);
expect(
(caught?.context as { missing: string[] } | undefined)?.missing
).toContain("app");
});
it("registers auditService and installs middleware", {
timeout: 5000,
}, async () => {
const sink = new MemoryAuditAdapter();
const plugin = createAuditPlugin({
sink,
secretKey: "x".repeat(32),
});
const app = new Hono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
await plugin.onInit?.(ctx);
expect(services.get("auditService")).toBeDefined();
app.get("/x", (c) => c.text("ok"));
await app.request("/x");
expect(sink.snapshot()).toHaveLength(1);
});
it("sink construction failure raises AUDIT_PLUGIN_SINK_INIT_FAILED", {
timeout: 5000,
}, async () => {
__setAuditLoggerFactory(() => {
throw new Error("simulated AuditLogger construction failure");
});
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
});
const app = new Hono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
let caught: AuditPluginError | undefined;
try {
await plugin.onInit?.(ctx);
} catch (err) {
caught = err as AuditPluginError;
}
expect(caught?.code).toBe(AUDIT_PLUGIN_CODES.SINK_INIT_FAILED);
expect(caught?.cause).toBeDefined();
});
it("warns when dev-default secret key is in use", {
timeout: 5000,
}, async () => {
let warnSeen = false;
const hostLogger = {
...noopLogger,
warn: () => {
warnSeen = true;
},
};
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
logger: hostLogger,
// no secretKey → dev default + warn
});
const app = new Hono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
await plugin.onInit?.(ctx);
expect(warnSeen).toBe(true);
});
it("onClose clears internal state without error", {
timeout: 5000,
}, async () => {
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
});
const app = new Hono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
await plugin.onInit?.(ctx);
await plugin.onClose?.(ctx);
});
it("uses ctx.logger when no custom logger supplied", {
timeout: 5000,
}, async () => {
let warnFromCtx = false;
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
// no secretKey → dev default + warn
});
const app = new Hono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = createPluginContext<AuditPluginConfig>({
logger: {
...noopLogger,
warn: () => {
warnFromCtx = true;
},
},
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuditPluginConfig,
services,
});
await plugin.onInit?.(ctx);
expect(warnFromCtx).toBe(true);
});
});

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,71 @@
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIT_PLUGIN_CODES,
type AuditPluginError,
} from "../error-codes/error-codes.js";
import type { AuditPluginConfig } from "../types/types.js";
import { createAuditPlugin } from "./plugin.js";
describe("createAuditPlugin", () => {
it("createAuditPlugin returns valid PluginDescriptor", {
timeout: 5000,
}, () => {
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
});
expect(plugin.name).toBe("audit");
expect(plugin.version).toBe("0.1.0");
expect(typeof plugin.onInit).toBe("function");
expect(typeof plugin.onClose).toBe("function");
});
it("invalid options raise AUDIT_PLUGIN_INVALID_INPUT", {
timeout: 5000,
}, () => {
let caught: AuditPluginError | undefined;
try {
createAuditPlugin({ samplingRate: 2 } as never);
} catch (err) {
caught = err as AuditPluginError;
}
expect(caught?.code).toBe(AUDIT_PLUGIN_CODES.INVALID_INPUT);
expect(caught?.cause).toBeDefined();
});
it("descriptor exposes resolved defaults", { timeout: 5000 }, () => {
const plugin = createAuditPlugin({
sink: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
samplingRate: 0.25,
});
expect((plugin.defaults as AuditPluginConfig).samplingRate).toBe(0.25);
expect((plugin.defaults as AuditPluginConfig).recordResponseSize).toBe(
true
);
});
it("falls back to MemoryAuditAdapter when no sink supplied", {
timeout: 5000,
}, () => {
const plugin = createAuditPlugin({ secretKey: "x".repeat(32) });
expect((plugin.defaults as AuditPluginConfig).sink).toBeDefined();
});
it("uses Math.random when no rng supplied", { timeout: 5000 }, () => {
const plugin = createAuditPlugin({ secretKey: "x".repeat(32) });
expect(typeof (plugin.defaults as AuditPluginConfig).rng).toBe(
"function"
);
});
it("accepts caller-supplied rng", { timeout: 5000 }, () => {
const rng = () => 0.5;
const plugin = createAuditPlugin({
secretKey: "x".repeat(32),
rng,
});
expect((plugin.defaults as AuditPluginConfig).rng).toBe(rng);
});
});

View File

@@ -0,0 +1,164 @@
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import {
AuditLogger,
type AuditStorageAdapter,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import type { LoggerLike } from "@suaveplan/types";
import {
AUDIT_PLUGIN_CODES,
AuditPluginError,
} from "../error-codes/error-codes.js";
import { createAuditMiddleware } from "../middleware/audit-middleware.js";
import {
AuditPluginOptionsSchema,
DEV_DEFAULT_SECRET_KEY,
} from "../schemas/options.js";
import { createAuditService } from "../services/audit-service.js";
import "../services/registry-augmentation.js";
import "../context/context-augmentation.js";
import type { AuditPluginConfig, AuditPluginOptions } from "../types/types.js";
interface AuditPluginState {
logger: AuditLogger | null;
}
type AuditLoggerFactory = (
opts: ConstructorParameters<typeof AuditLogger>[0]
) => AuditLogger;
const DEFAULT_AUDIT_LOGGER_FACTORY: AuditLoggerFactory = (opts) =>
new AuditLogger(opts);
let resolveAuditLoggerFactory: AuditLoggerFactory =
DEFAULT_AUDIT_LOGGER_FACTORY;
/**
* Test-only — swap the `AuditLogger` constructor used by `onInit` so
* unit tests can exercise the `SINK_INIT_FAILED` branch without
* relying on the underlying library to throw. Pass `undefined` to
* restore the default.
*/
export function __setAuditLoggerFactory(
factory: AuditLoggerFactory | undefined
): void {
resolveAuditLoggerFactory = factory ?? DEFAULT_AUDIT_LOGGER_FACTORY;
}
/**
* Factory — Zod-validates `options` and returns a typed
* `PluginDescriptor` for the audit plugin. Lifecycle:
*
* 1. Resolve `app` from the host `ServiceRegistry`. Missing → throw
* `AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)`.
* 2. Construct the `AuditLogger` with the supplied (or default
* in-memory) sink. Failures → throw
* `AuditPluginError(AUDIT_PLUGIN_SINK_INIT_FAILED)`.
* 3. Register `auditService` in the `ServiceRegistry`.
* 4. Install the global per-request audit middleware on `ctx.app`.
*/
export function createAuditPlugin(
options: AuditPluginOptions
): PluginDescriptor<AuditPluginConfig> {
const parsed = AuditPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AuditPluginError(
AUDIT_PLUGIN_CODES.INVALID_INPUT,
"createAuditPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const sink: AuditStorageAdapter =
(options.sink as AuditStorageAdapter | undefined) ??
new MemoryAuditAdapter();
const rng = typeof options.rng === "function" ? options.rng : Math.random;
const hostLoggerInput = options.logger as LoggerLike | undefined;
const config: AuditPluginConfig = {
...(hostLoggerInput !== undefined ? { logger: hostLoggerInput } : {}),
sink,
secretKey: parsed.data.secretKey,
samplingRate: parsed.data.samplingRate,
excludePaths: parsed.data.excludePaths,
redactQueryParams: parsed.data.redactQueryParams,
redactHeaders: parsed.data.redactHeaders,
redactBodyFields: parsed.data.redactBodyFields,
recordResponseSize: parsed.data.recordResponseSize,
recordRequestBody: parsed.data.recordRequestBody,
enableChainIntegrity: parsed.data.enableChainIntegrity,
rng,
};
const state: AuditPluginState = { logger: null };
return definePlugin<AuditPluginConfig>({
name: "audit",
version: "0.1.0",
description:
"Write-through audit trail for @suaveplan/api: global Hono middleware over @suaveplan/audit-logging.",
defaults: config,
tags: ["audit", "observability", "compliance"],
onInit: async (ctx) => runOnInit(ctx, config, state),
onClose: async () => {
state.logger = null;
},
});
}
async function runOnInit(
ctx: PluginContext<AuditPluginConfig>,
config: AuditPluginConfig,
state: AuditPluginState
): Promise<void> {
const app = ctx.getService("app");
if (app === undefined) {
throw new AuditPluginError(
AUDIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"audit plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const hostLogger = config.logger ?? ctx.logger;
if (config.secretKey === DEV_DEFAULT_SECRET_KEY) {
hostLogger.warn(
"audit plugin: using dev-default secret key — set `secretKey` in production"
);
}
let auditLogger: AuditLogger;
try {
auditLogger = resolveAuditLoggerFactory({
storage: config.sink,
secretKey: config.secretKey,
enableChainIntegrity: config.enableChainIntegrity,
logger: hostLogger,
});
} catch (err) {
throw new AuditPluginError(
AUDIT_PLUGIN_CODES.SINK_INIT_FAILED,
"AuditLogger construction failed",
{ cause: err, context: { statusCode: 500 } }
);
}
state.logger = auditLogger;
ctx.registerService(
"auditService",
createAuditService({ logger: auditLogger })
);
app.use(
"*",
createAuditMiddleware({
logger: auditLogger,
config,
hostLogger,
})
);
}

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,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DEFAULT_REDACTION_RULES, REDACTED, redactPayload } from "./redact.js";
describe("redactPayload", () => {
it("case-insensitive header redaction", { timeout: 5000 }, () => {
const out = redactPayload(
{
headers: {
Authorization: "Bearer x",
"X-Api-Key": "k",
"Content-Type": "application/json",
},
},
DEFAULT_REDACTION_RULES
);
expect(out.headers?.["Authorization"]).toBe(REDACTED);
expect(out.headers?.["X-Api-Key"]).toBe(REDACTED);
expect(out.headers?.["Content-Type"]).toBe("application/json");
});
it("query and body redaction by exact field name", {
timeout: 5000,
}, () => {
const out = redactPayload(
{
query: { token: "a", safe: "v" },
body: { password: "p", email: "e" },
},
DEFAULT_REDACTION_RULES
);
expect(out.query?.["token"]).toBe(REDACTED);
expect(out.query?.["safe"]).toBe("v");
expect(out.body?.["password"]).toBe(REDACTED);
expect(out.body?.["email"]).toBe("e");
});
it("default rules cover the canonical secret-bearing names", {
timeout: 5000,
}, () => {
expect(DEFAULT_REDACTION_RULES.headers).toContain("authorization");
expect(DEFAULT_REDACTION_RULES.headers).toContain("cookie");
expect(DEFAULT_REDACTION_RULES.query).toContain("token");
expect(DEFAULT_REDACTION_RULES.body).toContain("password");
});
it("returns empty result when payload is empty", {
timeout: 5000,
}, () => {
const out = redactPayload({}, DEFAULT_REDACTION_RULES);
expect(out.headers).toBeUndefined();
expect(out.query).toBeUndefined();
expect(out.body).toBeUndefined();
});
});

View File

@@ -0,0 +1,71 @@
import {
DEFAULT_REDACT_BODY,
DEFAULT_REDACT_HEADERS,
DEFAULT_REDACT_QUERY,
} from "../schemas/options.js";
import type { RedactablePayload, RedactionRules } from "../types/types.js";
/** Replacement marker substituted for matched secret-bearing values. */
export const REDACTED = "[REDACTED]";
/**
* Default redaction rules used by `createAuditPlugin` when the caller
* does not override any of the per-category lists.
*/
export const DEFAULT_REDACTION_RULES: RedactionRules = {
query: DEFAULT_REDACT_QUERY,
headers: DEFAULT_REDACT_HEADERS,
body: DEFAULT_REDACT_BODY,
};
function redactKeyValueExact(
record: Record<string, unknown>,
names: readonly string[]
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const matchSet = new Set(names);
for (const [k, v] of Object.entries(record)) {
out[k] = matchSet.has(k) ? REDACTED : v;
}
return out;
}
function redactHeadersCaseInsensitive(
headers: Record<string, string>,
names: readonly string[]
): Record<string, string> {
const out: Record<string, string> = {};
const lower = new Set(names.map((n) => n.toLowerCase()));
for (const [k, v] of Object.entries(headers)) {
out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
}
return out;
}
/**
* Returns a shallow clone of `payload` with the configured
* secret-bearing fields replaced by `"[REDACTED]"`. Header matches
* are case-insensitive; query and body matches are exact.
*/
export function redactPayload(
payload: RedactablePayload,
rules: RedactionRules
): RedactablePayload {
const out: RedactablePayload = {};
if (payload.query !== undefined) {
out.query = redactKeyValueExact(
payload.query as Record<string, unknown>,
rules.query
) as Record<string, string>;
}
if (payload.headers !== undefined) {
out.headers = redactHeadersCaseInsensitive(
payload.headers,
rules.headers
);
}
if (payload.body !== undefined) {
out.body = redactKeyValueExact(payload.body, rules.body);
}
return out;
}

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,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AuditPluginOptionsSchema,
DEFAULT_EXCLUDE_PATHS,
DEFAULT_REDACT_BODY,
DEFAULT_REDACT_HEADERS,
DEFAULT_REDACT_QUERY,
DEV_DEFAULT_SECRET_KEY,
} from "./options.js";
describe("AuditPluginOptionsSchema", () => {
it("applies all defaults for empty input", { timeout: 5000 }, () => {
const parsed = AuditPluginOptionsSchema.parse({});
expect(parsed.samplingRate).toBe(1);
expect(parsed.excludePaths).toEqual([...DEFAULT_EXCLUDE_PATHS]);
expect(parsed.redactQueryParams).toEqual([...DEFAULT_REDACT_QUERY]);
expect(parsed.redactHeaders).toEqual([...DEFAULT_REDACT_HEADERS]);
expect(parsed.redactBodyFields).toEqual([...DEFAULT_REDACT_BODY]);
expect(parsed.recordResponseSize).toBe(true);
expect(parsed.recordRequestBody).toBe(false);
expect(parsed.enableChainIntegrity).toBe(false);
expect(parsed.secretKey).toBe(DEV_DEFAULT_SECRET_KEY);
});
it("rejects samplingRate out of [0,1]", { timeout: 5000 }, () => {
const result = AuditPluginOptionsSchema.safeParse({
samplingRate: 2,
});
expect(result.success).toBe(false);
});
it("rejects negative samplingRate", { timeout: 5000 }, () => {
const result = AuditPluginOptionsSchema.safeParse({
samplingRate: -0.1,
});
expect(result.success).toBe(false);
});
it("honors caller-supplied secretKey", { timeout: 5000 }, () => {
const parsed = AuditPluginOptionsSchema.parse({
secretKey: "x".repeat(32),
});
expect(parsed.secretKey).toBe("x".repeat(32));
});
it("honors caller-supplied excludePaths and redaction lists", {
timeout: 5000,
}, () => {
const parsed = AuditPluginOptionsSchema.parse({
excludePaths: ["/foo"],
redactQueryParams: ["custom"],
redactHeaders: ["x-secret"],
redactBodyFields: ["ssn"],
});
expect(parsed.excludePaths).toEqual(["/foo"]);
expect(parsed.redactQueryParams).toEqual(["custom"]);
expect(parsed.redactHeaders).toEqual(["x-secret"]);
expect(parsed.redactBodyFields).toEqual(["ssn"]);
});
});

View File

@@ -0,0 +1,88 @@
import z from "zod";
import type { AuditPluginConfig } from "../types/types.js";
/**
* Default secret-bearing header / query / body names redacted by the
* audit middleware. Header matches are case-insensitive.
*/
export const DEFAULT_REDACT_HEADERS = [
"authorization",
"cookie",
"set-cookie",
"x-api-key",
] as const;
export const DEFAULT_REDACT_QUERY = ["token", "code", "state"] as const;
export const DEFAULT_REDACT_BODY = ["password", "secret", "token"] as const;
export const DEFAULT_EXCLUDE_PATHS = [
"/health",
"/healthz",
"/ready",
"/openapi.json",
] as const;
/**
* 32-byte hex placeholder secret used when the operator did not supply
* one. The factory logs a warn (via the resolved logger) when it
* falls back to this value; suitable for dev only.
*/
const DEV_DEFAULT_SECRET_KEY = "dev-audit-secret-please-override";
/**
* Zod schema validating `AuditPluginOptions` at the factory boundary.
* Returns the resolved `AuditPluginConfig` shape with defaults filled.
*/
export const AuditPluginOptionsSchema = z
.object({
logger: z.unknown().optional(),
sink: z.unknown().optional(),
secretKey: z.string().min(1).optional(),
samplingRate: z.number().min(0).max(1).default(1),
excludePaths: z.array(z.string()).default([...DEFAULT_EXCLUDE_PATHS]),
redactQueryParams: z
.array(z.string())
.default([...DEFAULT_REDACT_QUERY]),
redactHeaders: z.array(z.string()).default([...DEFAULT_REDACT_HEADERS]),
redactBodyFields: z.array(z.string()).default([...DEFAULT_REDACT_BODY]),
recordResponseSize: z.boolean().default(true),
recordRequestBody: z.boolean().default(false),
enableChainIntegrity: z.boolean().default(false),
rng: z.unknown().optional(),
})
.transform((parsed) => {
const resolved = {
...(parsed.logger !== undefined ? { logger: parsed.logger } : {}),
...(parsed.sink !== undefined ? { sink: parsed.sink } : {}),
secretKey: parsed.secretKey ?? DEV_DEFAULT_SECRET_KEY,
samplingRate: parsed.samplingRate,
excludePaths: parsed.excludePaths,
redactQueryParams: parsed.redactQueryParams,
redactHeaders: parsed.redactHeaders,
redactBodyFields: parsed.redactBodyFields,
recordResponseSize: parsed.recordResponseSize,
recordRequestBody: parsed.recordRequestBody,
enableChainIntegrity: parsed.enableChainIntegrity,
...(parsed.rng !== undefined ? { rng: parsed.rng } : {}),
};
return resolved as unknown as Partial<AuditPluginConfig> & {
secretKey: string;
samplingRate: number;
excludePaths: readonly string[];
redactQueryParams: readonly string[];
redactHeaders: readonly string[];
redactBodyFields: readonly string[];
recordResponseSize: boolean;
recordRequestBody: boolean;
enableChainIntegrity: boolean;
};
});
export type AuditPluginOptionsInput = z.input<typeof AuditPluginOptionsSchema>;
export type AuditPluginOptionsResolved = z.output<
typeof AuditPluginOptionsSchema
>;
export { DEV_DEFAULT_SECRET_KEY };

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,30 @@
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import "./registry-augmentation.js";
import type { ServiceRegistry } from "@suaveplan/api-plugin";
import type { AuditService } from "../types/types.js";
import { createAuditService } from "./audit-service.js";
describe("services/audit-service spec-conformance", () => {
it("ServiceRegistry slot typed as AuditService", { timeout: 5000 }, () => {
type Slot = ServiceRegistry["auditService"];
expectTypeOf<Slot>().toEqualTypeOf<AuditService>();
});
it("exposes the 6 declared methods at runtime", {
timeout: 5000,
}, () => {
const service = createAuditService({
logger: new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: "x".repeat(32),
}),
});
expect(typeof service.log).toBe("function");
expect(typeof service.query).toBe("function");
expect(typeof service.getLogger).toBe("function");
expect(typeof service.verifyChainIntegrity).toBe("function");
expect(typeof service.exportGDPRData).toBe("function");
expect(typeof service.applyRetentionPolicy).toBe("function");
});
});

View File

@@ -0,0 +1,86 @@
import {
AuditLogger,
AuditSeverity,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createAuditService } from "./audit-service.js";
function makeService() {
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
return { service: createAuditService({ logger }), storage, logger };
}
describe("createAuditService", () => {
it("log proxies to underlying AuditLogger", { timeout: 5000 }, async () => {
const { service, storage } = makeService();
await service.log({
type: "test.event",
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
});
const all = storage.snapshot();
expect(all).toHaveLength(1);
expect(all[0]?.actor.userId).toBe("u1");
});
it("query proxies to underlying AuditLogger", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await service.log({
type: "test.event",
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
});
const results = await service.query({ userId: "u1" });
expect(results).toHaveLength(1);
});
it("getLogger returns the underlying AuditLogger", {
timeout: 5000,
}, () => {
const { service, logger } = makeService();
expect(service.getLogger()).toBe(logger);
});
it("verifyChainIntegrity proxies to underlying AuditLogger", {
timeout: 5000,
}, async () => {
const { service } = makeService();
const evt = await service.log({
type: "x",
severity: AuditSeverity.INFO,
actor: {},
});
const verdict = await service.verifyChainIntegrity([evt]);
expect(verdict.valid).toBe(true);
});
it("exportGDPRData proxies to underlying AuditLogger", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await service.log({
type: "x",
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
});
const ex = await service.exportGDPRData("u1");
expect(ex.userId).toBe("u1");
expect(ex.summary.totalEvents).toBe(1);
});
it("applyRetentionPolicy proxies to underlying AuditLogger", {
timeout: 5000,
}, async () => {
const { service } = makeService();
const result = await service.applyRetentionPolicy();
expect(result.eventsDeleted).toBe(0);
expect(result.errors).toEqual([]);
});
});

View File

@@ -0,0 +1,52 @@
import type {
AuditEvent,
AuditLogger,
AuditQueryOptions,
ChainVerificationResult,
GDPRDataExport,
RetentionResult,
} from "@suaveplan/audit-logging";
import type { AuditService } from "../types/types.js";
/**
* Constructs the `AuditService` adapter that wraps a constructed
* `AuditLogger` from `@suaveplan/audit-logging`. The wrapper exists
* so consumers depend on a stable interface (declared in
* `../types/types.ts`) regardless of evolution in the underlying
* library.
*/
export function createAuditService(deps: {
logger: AuditLogger;
}): AuditService {
const { logger } = deps;
return {
getLogger(): AuditLogger {
return logger;
},
log(
event: Omit<
AuditEvent,
"id" | "timestamp" | "hash" | "previousHash"
>
): Promise<AuditEvent> {
return logger.log(event);
},
query(opts: AuditQueryOptions): Promise<AuditEvent[]> {
return logger.query(opts);
},
verifyChainIntegrity(
events: AuditEvent[]
): Promise<ChainVerificationResult> {
return logger.verifyChainIntegrity(events);
},
exportGDPRData(
userId: string,
signal?: AbortSignal
): Promise<GDPRDataExport> {
return logger.exportGDPRData(userId, signal);
},
applyRetentionPolicy(signal?: AbortSignal): Promise<RetentionResult> {
return logger.applyRetentionPolicy(signal);
},
};
}

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,23 @@
/**
* ServiceRegistry module augmentation — declares the `auditService`
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented registry
* see `ctx.getService("auditService")` resolve to `AuditService |
* undefined` without any cast.
*
* Also re-declares the host-side slots the plugin reads (`app`) as a
* cumulative augmentation so the audit plugin's `onInit(ctx)` can
* resolve them with full typing even when the consumer has not
* explicitly imported `@suaveplan/api` (e.g. during isolated
* unit-tests of the plugin). TypeScript merges duplicate declarations,
* so this is safe alongside `@suaveplan/api`'s declarations.
*/
import type { Hono } from "hono";
import type { AuditService } from "../types/types.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
auditService: AuditService;
app: Hono;
}
}

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`.

View File

@@ -0,0 +1,101 @@
import type {
AuditEvent,
AuditLogger,
AuditQueryOptions,
AuditStorageAdapter,
ChainVerificationResult,
GDPRDataExport,
RetentionResult,
} from "@suaveplan/audit-logging";
import type { LoggerLike } from "@suaveplan/types";
/**
* Caller-supplied options for `createAuditPlugin`. Validated at the
* factory boundary by `AuditPluginOptionsSchema`.
*/
export interface AuditPluginOptions {
logger?: LoggerLike;
sink?: AuditStorageAdapter;
secretKey?: string;
samplingRate?: number;
excludePaths?: readonly string[];
redactQueryParams?: readonly string[];
redactHeaders?: readonly string[];
redactBodyFields?: readonly string[];
recordResponseSize?: boolean;
recordRequestBody?: boolean;
enableChainIntegrity?: boolean;
rng?: () => number;
}
/**
* Resolved (post-validation) plugin config. All optional fields from
* the input are defaulted; identity-bearing fields stay optional.
*/
export interface AuditPluginConfig {
logger?: LoggerLike;
sink: AuditStorageAdapter;
secretKey: string;
samplingRate: number;
excludePaths: readonly string[];
redactQueryParams: readonly string[];
redactHeaders: readonly string[];
redactBodyFields: readonly string[];
recordResponseSize: boolean;
recordRequestBody: boolean;
enableChainIntegrity: boolean;
rng: () => number;
}
/**
* Payload shape passed to `redactPayload` — every field is optional,
* so callers can redact only what they have.
*/
export interface RedactablePayload {
query?: Record<string, string>;
headers?: Record<string, string>;
body?: Record<string, unknown>;
}
/**
* Rules supplied to `redactPayload`. Field names are matched exactly
* for `query` and `body`; case-insensitively for `headers`.
*/
export interface RedactionRules {
query: readonly string[];
headers: readonly string[];
body: readonly string[];
}
/**
* Public surface registered in `ServiceRegistry` under `auditService`.
* Proxies the underlying `AuditLogger` with a stable surface that
* survives changes in `@suaveplan/audit-logging`.
*/
export interface AuditService {
getLogger(): AuditLogger;
log(
event: Omit<AuditEvent, "id" | "timestamp" | "hash" | "previousHash">
): Promise<AuditEvent>;
query(opts: AuditQueryOptions): Promise<AuditEvent[]>;
verifyChainIntegrity(
events: AuditEvent[]
): Promise<ChainVerificationResult>;
exportGDPRData(
userId: string,
signal?: AbortSignal
): Promise<GDPRDataExport>;
applyRetentionPolicy(signal?: AbortSignal): Promise<RetentionResult>;
}
/**
* Per-request audit context attached to `c.set("audit", ...)` by the
* middleware. Handlers call `c.get("audit").log(...)` to record
* explicit, semantic events alongside the implicit request audit.
*/
export interface AuditContext {
log(
event: Omit<AuditEvent, "id" | "timestamp" | "hash" | "previousHash">
): Promise<AuditEvent>;
getLogger(): AuditLogger;
}

View File

@@ -0,0 +1,59 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json" with { type: "json" };
const ROOT = resolve(__dirname, "..");
const SRC = join(ROOT, "src");
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full, out);
else out.push(full);
}
return out;
}
describe("dep-graph", () => {
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
for (const f of walk(SRC)) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
if (f.endsWith(".spec-conformance.test.ts")) continue;
if (f.endsWith(".types.ts")) continue;
if (f.endsWith("/index.ts")) continue;
const md = f.replace(/\.ts$/, ".md");
if (!existsSync(md)) missing.push(relative(ROOT, md));
}
expect(missing).toEqual([]);
});
it("every subpath export resolves to a real source file", {
timeout: 5000,
}, () => {
const entries = pkg.exports as Record<string, unknown>;
const missing: string[] = [];
for (const [key, value] of Object.entries(entries)) {
if (key === "./package.json") continue;
// The value is either string OR { import: { default: "..." } }
const distPath =
typeof value === "object" && value !== null
? (
value as {
import?: { default?: string };
}
).import?.default
: (value as string);
if (typeof distPath !== "string") continue;
const srcPath = distPath
.replace(/^\.\/dist\//, "./src/")
.replace(/\.js$/, ".ts");
const abs = join(ROOT, srcPath);
if (!existsSync(abs)) missing.push(`${key} → ${srcPath}`);
}
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,111 @@
import { createPluginContext } from "@suaveplan/api-plugin";
import {
type AuditEvent,
AuditSeverity,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { createAuditPlugin } from "../../src/plugin/plugin.js";
import type { AuditPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("full-boot integration", () => {
it("real api + plugin + sample route records redacted audit with principal and tenant", {
timeout: 10000,
}, async () => {
const sink = new MemoryAuditAdapter();
const plugin = createAuditPlugin({
sink,
secretKey: "x".repeat(32),
redactQueryParams: ["token"],
redactHeaders: ["authorization"],
redactBodyFields: ["password"],
recordRequestBody: true,
});
const app = new Hono();
const services = new Map<string, unknown>();
services.set("app", app);
// Drive lifecycle hooks the way @suaveplan/api would.
const ctx = createPluginContext<AuditPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuditPluginConfig,
services,
});
await plugin.onInit?.(ctx);
// Pretend auth + tenant plugins have already populated the context.
app.use("*", async (c, next) => {
c.set("user", { id: "u1" });
c.set("tenant", { id: "t1" });
await next();
});
// Sample route that ALSO fires an explicit semantic event via
// c.get("audit") to prove the handler-level helper is wired.
app.post("/things/:id", async (c) => {
const audit = c.get("audit");
await audit.log({
type: "thing.updated",
severity: AuditSeverity.WARNING,
actor: { userId: "u1" },
target: { type: "thing", id: c.req.param("id") },
});
return c.json({ updated: true });
});
const res = await app.request("/things/42?token=secret&safe=v", {
method: "POST",
headers: {
Authorization: "Bearer top-secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ password: "p", note: "hi" }),
});
expect(res.status).toBe(200);
const events = sink.snapshot();
expect(events.length).toBeGreaterThanOrEqual(2);
const requestEvt = events.find((e) =>
String(e.type).startsWith("http.")
) as AuditEvent | undefined;
expect(requestEvt).toBeDefined();
const det = requestEvt?.details as {
method: string;
path: string;
status: number;
duration_ms: number;
query: Record<string, string>;
headers: Record<string, string>;
body?: Record<string, unknown>;
};
expect(det.method).toBe("POST");
expect(det.path).toBe("/things/42");
expect(det.status).toBe(200);
expect(det.duration_ms).toBeGreaterThanOrEqual(0);
expect(det.query["token"]).toBe("[REDACTED]");
expect(det.query["safe"]).toBe("v");
expect(det.headers["authorization"]).toBe("[REDACTED]");
expect(det.body?.["password"]).toBe("[REDACTED]");
expect(det.body?.["note"]).toBe("hi");
expect(requestEvt?.actor.userId).toBe("u1");
expect(requestEvt?.tenantId).toBe("t1");
const semanticEvt = events.find((e) => e.type === "thing.updated") as
| AuditEvent
| undefined;
expect(semanticEvt).toBeDefined();
expect(semanticEvt?.target?.id).toBe("42");
});
});

View File

@@ -0,0 +1,71 @@
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { createAuditMiddleware } from "../../src/middleware/audit-middleware.js";
import type { AuditPluginConfig } from "../../src/types/types.js";
const noopHostLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
/**
* Deterministic Mulberry32 RNG so the test is reproducible across
* Bun + Node runtime engines.
*/
function mulberry32(seed: number): () => number {
let t = seed >>> 0;
return () => {
t = (t + 0x6d2b79f5) >>> 0;
let r = t;
r = Math.imul(r ^ (r >>> 15), r | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
describe("sampling integration", () => {
it("50% sample rate over 1000 requests sits in [400, 600] band", {
timeout: 30000,
}, async () => {
const storage = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage,
secretKey: "x".repeat(32),
});
const config: AuditPluginConfig = {
sink: storage,
secretKey: "x".repeat(32),
samplingRate: 0.5,
excludePaths: [],
redactQueryParams: [],
redactHeaders: [],
redactBodyFields: [],
recordResponseSize: false,
recordRequestBody: false,
enableChainIntegrity: false,
rng: mulberry32(1234567),
};
const app = new Hono();
app.use(
"*",
createAuditMiddleware({
logger,
config,
hostLogger: noopHostLogger,
})
);
app.get("/x", (c) => c.text("ok"));
for (let i = 0; i < 1000; i += 1) {
await app.request(`/x?i=${i}`);
}
const count = storage.snapshot().length;
expect(count).toBeGreaterThanOrEqual(400);
expect(count).toBeLessThanOrEqual(600);
});
});

View File

@@ -0,0 +1,14 @@
// Test-only Hono ContextVariableMap shim — adds the slots normally
// contributed by sibling plugins (`@suaveplan/api-plugin-auth`,
// `@suaveplan/api-tenant`, `@suaveplan/api`) so test files can call
// `c.set("user", ...)`, `c.set("tenant", ...)`, `c.set("requestId", ...)`
// without ts2769 errors when the sibling packages are not direct deps.
import "hono";
declare module "hono" {
interface ContextVariableMap {
user: { id?: string } | null | undefined;
tenant: { id?: string } | null | undefined;
requestId: string;
}
}

View File

@@ -0,0 +1,32 @@
/**
* Test setup for @suaveplan/api-plugin-audit.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "tests/**/*.d.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-audit",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});