Files
suaveplan/packages/api-stack/api/api-plugin-site/docs/middleware-integration.md
2026-05-27 23:29:53 +01:00

113 lines
5.5 KiB
Markdown

# Middleware composition — site + rate-limit + audit
`@suaveplan/api-plugin-site` ships **zero runtime dependencies** on `@suaveplan/api-plugin-rate-limit` and `@suaveplan/api-plugin-audit`. This is deliberate. The composition knob lives in the host application: callers wire the middleware via Hono's `app.use(prefix, middleware)` pattern so they can swap or skip implementations as their deployment requires.
This guide documents the canonical pattern, the recommended layering order, and a copy-pasteable end-to-end example. A runnable verification of every assertion lives in `src/integration/middleware-composition.integration.test.ts`.
## Layering order
```
┌────────────────────────────────────────┐
inbound │ audit middleware (OUTERMOST) │
request ──▶ │ ──▶ rate-limit middleware │
│ ──▶ site routes (INNERMOST) │
└────────────────────────────────────────┘
```
The order matters and is **not interchangeable**:
1. **Audit OUTERMOST.** The audit middleware records one event per inbound request — including requests that are rejected by rate-limit with `429`. Putting audit inside rate-limit would silently drop the audit record on every throttled call, defeating the purpose of an immutable trail. The audit middleware is also a no-throw boundary by contract, so it adds no failure modes.
2. **Rate-limit MIDDLE.** The rate-limit middleware is mounted on the site prefix (`/sites/*`) so it does not consume budget for unrelated routes mounted on the same host (health checks, OpenAPI documents, etc). Putting rate-limit inside the site routes would force every site route to opt in by hand and would miss requests that hit `404` route stubs.
3. **Site routes INNERMOST.** The site plugin's `createPrivateSiteRoutes` and `createPublicSiteRoutes` factories return `OpenAPIHono` instances that are mounted via `app.route(prefix, routes)`. They make zero assumptions about the surrounding middleware stack.
## Canonical example
```ts
import {
AuditLogger,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit";
import {
createDefaultStorage,
createRateLimitService,
rateLimit,
RateLimitPluginOptionsSchema,
} from "@suaveplan/api-plugin-rate-limit";
import {
createPrivateSiteRoutes,
createPublicSiteRoutes,
MIDDLEWARE_RECOMMENDATIONS,
SiteService,
} from "@suaveplan/api-plugin-site";
import { Hono } from "hono";
// ── 1. Site service + routes ────────────────────────────────────────
const siteService = new SiteService(db, mergeConfig());
const privateRoutes = createPrivateSiteRoutes({ siteService });
const publicRoutes = createPublicSiteRoutes({ domainResolver });
// ── 2. Rate-limit service driven by the recommendations bundle ─────
const clock = () => Date.now();
const rateLimitConfig = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: MIDDLEWARE_RECOMMENDATIONS.map((r) => ({
id: r.routeGroup,
path: r.rateLimit.pathPattern,
limit: r.rateLimit.limit,
windowMs: r.rateLimit.windowMs,
})),
});
const rateLimitService = createRateLimitService({
config: rateLimitConfig,
storage: createDefaultStorage({ clock }),
clock,
logger: hostLogger,
});
// ── 3. Audit logger backed by your AuditStorageAdapter ─────────────
const auditSink = new MemoryAuditAdapter();
const auditLogger = new AuditLogger({
storage: auditSink,
secretKey: process.env.AUDIT_SECRET ?? "x".repeat(32),
enableChainIntegrity: true,
});
const auditMiddleware = createAuditMiddleware({
logger: auditLogger,
config: {
sink: auditSink,
secretKey: process.env.AUDIT_SECRET ?? "x".repeat(32),
samplingRate: 1,
excludePaths: ["/health"],
redactQueryParams: ["token"],
redactHeaders: ["authorization", "cookie"],
redactBodyFields: ["password", "secret"],
recordResponseSize: true,
recordRequestBody: false,
enableChainIntegrity: true,
rng: Math.random,
},
hostLogger,
});
// ── 4. Compose: audit outermost → rate-limit → site routes ────────
const app = new Hono();
app.use("*", auditMiddleware);
app.use("/sites/*", rateLimit({ service: rateLimitService }));
app.route("/", publicRoutes);
app.route("/", privateRoutes);
```
## Recommendations
The `MIDDLEWARE_RECOMMENDATIONS` constant exported from this package describes the recommended rate-limit ceilings and audit categories per documented route group (`public:resolve`, `public:read`, `private:write`). See `src/integration/middleware-recommendations.md` for the per-field rationale and an advanced consumption example.
## Verification
The integration test at `src/integration/middleware-composition.integration.test.ts` boots a real Hono app composed exactly as documented above, exercises `POST /sites`, and asserts:
- The response status is `201` and the site row is persisted (site behaviour preserved).
- `X-RateLimit-Limit` is present on the response (rate-limit observed the request).
- The audit sink contains exactly one `AuditEvent` with `details.method === "POST"` and `details.path === "/sites"`.