113 lines
5.5 KiB
Markdown
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"`.
|