Initial Commit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# 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"`.
|
||||
Reference in New Issue
Block a user