5.5 KiB
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:
- 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. - 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 hit404route stubs. - Site routes INNERMOST. The site plugin's
createPrivateSiteRoutesandcreatePublicSiteRoutesfactories returnOpenAPIHonoinstances that are mounted viaapp.route(prefix, routes). They make zero assumptions about the surrounding middleware stack.
Canonical example
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
201and the site row is persisted (site behaviour preserved). X-RateLimit-Limitis present on the response (rate-limit observed the request).- The audit sink contains exactly one
AuditEventwithdetails.method === "POST"anddetails.path === "/sites".