first commit
This commit is contained in:
88
docs/api-stack/api/api-plugin-audit/README.md
Normal file
88
docs/api-stack/api/api-plugin-audit/README.md
Normal 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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
56
docs/api-stack/api/api-plugin-audit/src/otel/instruments.md
Normal file
56
docs/api-stack/api/api-plugin-audit/src/otel/instruments.md
Normal 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.
|
||||
62
docs/api-stack/api/api-plugin-audit/src/plugin/plugin.md
Normal file
62
docs/api-stack/api/api-plugin-audit/src/plugin/plugin.md
Normal 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.
|
||||
70
docs/api-stack/api/api-plugin-audit/src/redact/redact.md
Normal file
70
docs/api-stack/api/api-plugin-audit/src/redact/redact.md
Normal 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`.
|
||||
64
docs/api-stack/api/api-plugin-audit/src/schemas/options.md
Normal file
64
docs/api-stack/api/api-plugin-audit/src/schemas/options.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
65
docs/api-stack/api/api-plugin-audit/src/types/types.md
Normal file
65
docs/api-stack/api/api-plugin-audit/src/types/types.md
Normal 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`.
|
||||
Reference in New Issue
Block a user