Superfunction

@spfn/core/middleware

Production Hono middleware factories and a masking helper: ErrorHandler (serializes thrown errors into HTTP responses), RequestLogger (structured request/response logging with request IDs, slow-request detection, and sensitive-data masking), createProxyGuard (verifies a trusted-proxy HMAC signature + origin allowlist and tags clientType), and rateLimit (Redis-backed fixed-window limiter for brute-force / DoS protection).

These are the exports of this module. defineMiddleware (custom named middleware), .use() / .skip() (route-level wiring), and Transactional (DB transaction middleware) are not here — they live in @spfn/core/route and @spfn/core/db. See Related.

Import paths

import {
    ErrorHandler,
    RequestLogger,
    maskSensitiveData,
    createProxyGuard,
    createCacheNonceStore,
    rateLimit,
    getClientIp,
} from '@spfn/core/middleware';

import type {
    ErrorHandlerOptions,
    OnErrorContext,
    RequestLoggerOptions,
    RequestLoggerConfig, // deprecated alias of RequestLoggerOptions
    ProxyGuardConfig,
    ProxyGuardMode,
    ClientType,
    NonceStore,
    RateLimitOptions,
} from '@spfn/core/middleware';

There is no @spfn/core root barrel — always import from @spfn/core/middleware. import { ErrorHandler } from '@spfn/core' does not resolve.


Public API (complete)

From @spfn/core/middleware:

  • ErrorHandler(options?: ErrorHandlerOptions)(err, c) => Response | Promise<Response> — register with app.onError(...), not app.use(...).
  • RequestLogger(options?: RequestLoggerOptions) → Hono middleware (c, next) => Promise<void> — register with app.use(...).
  • maskSensitiveData(obj, sensitiveFields, seen?) → deep-masked copy of obj.
  • createProxyGuard(config?: ProxyGuardConfig) → Hono middleware. Verifies the proxy→backend HMAC signature (rotating key set, keyed by keyId) over method+path+query+body, plus an optional origin allowlist, then sets c.get('clientType') ('web' | 'untrusted'). Modes: off (default) / tag / strict — every gate is evaluated in BOTH modes, only enforcement differs (strict rejects 403/413, tag tags untrusted and continues). maxBodyBytes caps the hashed body (stream-measured). Usually enabled via defineServerConfig().proxyGuard({...}), not app.use directly.
  • createCacheNonceStore(cache, prefix?)NonceStore for hard replay rejection (Redis SET … PX NX). Pass to proxyGuard.nonceStore (auto-wired from a cache when nonce: true).
  • rateLimit(options: RateLimitOptions) → Hono middleware. Redis-backed fixed-window limiter, registered under the named 'rateLimit' slot so routes can .skip(['rateLimit']). Attach with .use([rateLimit({...})]). See rateLimit.
  • getClientIp(c) → best-effort client IP from the proxy chain (leftmost X-Forwarded-For, then X-Real-IP, else 'unknown'). Spoofable — see the caveat in rateLimit.

Types: ErrorHandlerOptions, OnErrorContext, RequestLoggerOptions, RequestLoggerConfig, ProxyGuardConfig, ProxyGuardMode, ClientType, NonceStore, RateLimitOptions.

See the root PROXY-BACKEND-AUTH-SPEC.md for the threat model, key rotation, and .env placement (SPFN_PROXY_SECRET in .env.local; grace SPFN_PROXY_SECRET_PREVIOUS in .env.server).

RequestLoggerConfig is deprecated — it is a type alias of RequestLoggerOptions. Use RequestLoggerOptions in new code.

Most SPFN apps never call these directly. createServer / defineServerConfig auto-register both — see Auto-registration below. Manual app.use / app.onError is the raw-Hono path.


Auto-registration (the SPFN default path)

When you start a server via @spfn/core/server, RequestLogger and ErrorHandler are applied for you (along with CORS). You do not wire them by hand.

import { defineServerConfig } from '@spfn/core/server';

export default defineServerConfig()
    .routes(appRouter)
    .build();
// → RequestLogger() (no options), CORS, then ErrorHandler() are auto-applied.

Toggle / configure them through config.middleware:

export default defineServerConfig()
    .middleware({
        logger: true,         // RequestLogger (default: true) — set false to disable
        cors: true,           // CORS (default: true)
        errorHandler: true,   // ErrorHandler (default: true) — set false to disable
        onError: (err, ctx) =>  // forwarded into ErrorHandler({ onError })
        {
            // non-blocking side-effect (Slack, PagerDuty, ...)
            log(ctx.statusCode, ctx.method, ctx.path, err.message);
        },
    })
    .routes(appRouter)
    .build();

Notes (from create-server.ts):

  • The auto-applied RequestLogger() is called with no options, so it uses the defaults below (excludes /health, /ping, /favicon.ico). To customize excludePaths etc., disable it (middleware.logger: false) and add your own via config.use.
  • config.middleware.onError is the only ErrorHandlerOption exposed through the builder; includeStack / enableLogging fall back to their defaults under auto-config.
  • Order is fixed: RequestLogger → CORS → routes → ErrorHandler (via app.onError).

Quick Start (raw Hono)

Use this only when wiring a bare Hono app yourself (not via defineServerConfig).

import { Hono } from 'hono';
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';

const app = new Hono();

app.use('*', RequestLogger());     // middleware — runs per request
app.onError(ErrorHandler());       // error hook — NOT app.use()

export default app;

ErrorHandler

Converts a thrown error into a JSON HTTP response. Register it with app.onError() (it is Hono's error hook, not a use() middleware).

Behavior

  • SerializableError (from @spfn/core/errors) → serialized via its toJSON(), using the error's own statusCode as the HTTP status. Custom fields (resource, fields, …) are preserved. Detection is duck-typed (toJSON + numeric statusCode) so it survives module duplication under tsx/dev.
  • Standard Error → falls back to { __type: 'Error', message }, status from a statusCode property on the error if present, else 500.
  • cause chain → the root cause message is extracted and added as cause.
  • Production information disclosure → when includeStack is false (the production default), the client message is genericized for anything whose text may come from the DB driver: standard (uncaught) Errors become "Internal Server Error" with no cause, and any error exposing internal === true (the DatabaseError family — QueryError, ConnectionError, TransactionError, … carrying raw SQL / table / column / parameter text) becomes "Internal server error" with no details. Full detail is still logged server-side. Errors with a safe, constructed message (EntityNotFoundError, DuplicateEntryError, and all non-DB SerializableErrors) are returned unchanged.
  • Logging (when enableLogging): warn for 4xx, error for 5xx, via the @spfn/core:error-handler logger.
  • onError callback → fired non-blocking (Promise.resolve(...).catch(...)); never delays or fails the response.

Options (ErrorHandlerOptions)

Option Type Default Effect
includeStack boolean env.NODE_ENV !== 'production' Add stack to the response body
enableLogging boolean true Log errors (warn 4xx / error 5xx)
onError (err, ctx: OnErrorContext) => void | Promise<void> Non-blocking side-effect callback
app.onError(ErrorHandler({
    includeStack: env.NODE_ENV !== 'production',
    enableLogging: true,
    onError: (err, ctx) => notify(ctx.statusCode, ctx.path, err),
}));

Response format

SerializableError (e.g. NotFoundError) — body is its toJSON() output; HTTP status is the error's statusCode:

// production
{ "__type": "NotFoundError", "message": "User not found", "resource": "User" }

// development (includeStack) adds:
{ "__type": "NotFoundError", "message": "User not found", "resource": "User",
  "stack": "Error: User not found\n    at ..." }

Standard error fallback:

{ "__type": "Error", "message": "Internal Server Error" }
// + "cause": "..."  when the error has a cause
// + "stack": "..."  only when includeStack is true

The serialized field is __type, and the status code is carried by the HTTP status, not a body field. (Older docs showing { "error": "...", "statusCode": 400 } are stale.)

OnErrorContext

interface OnErrorContext {
    statusCode: number;
    path: string;
    method: string;
    requestId?: string;   // present when RequestLogger ran first
    timestamp: string;    // ISO 8601
    userId?: string;      // from c.get('auth')?.userId, when auth middleware set it
    request: {
        headers: Record<string, string>;  // sensitive headers masked to '***'
        query: Record<string, string>;
    };
}

Masked request headers: authorization, cookie, x-api-key, x-auth-token (case-insensitive). requestId and userId are only populated when the corresponding upstream middleware (RequestLogger / auth) has run.


RequestLogger

Per-request logging middleware. Register with app.use().

Behavior

  • Generates a request ID (req_<timestamp>_<6-byte hex>) and stores it on the context: c.set('requestId', id) → read via c.get('requestId').
  • Logs Request received (method, path, ip, userAgent) and Request completed (status, duration). Client IP is taken from x-forwarded-for (first hop) → x-real-ip'unknown'.
  • Log level by status: info (<400), warn (4xx), error (5xx). Logger child: @spfn/core:api.
  • Slow requests (duration >= slowRequestThreshold) get slow: true.
  • For 4xx/5xx: clones the response to attach the error response body, and for POST/PUT/PATCH attaches the masked request body (request).
  • If the downstream throws, logs Request failed at error level and re-throws (so app.onError / ErrorHandler still runs). It does not swallow errors.

Options (RequestLoggerOptions)

Option Type Default
excludePaths string[] ['/health', '/ping', '/favicon.ico']
sensitiveFields string[] ['password', 'token', 'apiKey', 'secret', 'authorization']
slowRequestThreshold number (ms) 1000

excludePaths matches exact or prefix/health also excludes /health/db. Excluded paths skip logging entirely (and get no request ID).

app.use('*', RequestLogger({
    excludePaths: ['/health', '/metrics', '/_next'],
    sensitiveFields: ['password', 'creditCard', 'ssn'],
    slowRequestThreshold: 500,
}));

Reading the request ID

// in a route handler
const requestId = c.get('requestId'); // string | undefined

Log output examples

// Request received
{ "level": "info", "module": "api", "msg": "Request received",
  "requestId": "req_1759541628730_qsm7esvo7", "method": "POST", "path": "/users",
  "ip": "127.0.0.1", "userAgent": "..." }

// completed (success)
{ "level": "info", "module": "api", "msg": "Request completed",
  "requestId": "req_...", "method": "POST", "path": "/users", "status": 201, "duration": 45 }

// completed (4xx — includes response body + masked request body)
{ "level": "warn", "module": "api", "msg": "Request completed",
  "status": 400, "duration": 2,
  "response": { "__type": "ValidationError", "message": "Invalid request body" },
  "request": { "status": 123, "password": "***MASKED***" } }

// slow
{ "level": "info", "msg": "Request completed", "status": 200, "duration": 1250, "slow": true }

// downstream threw (then re-thrown to ErrorHandler)
{ "level": "error", "module": "api", "msg": "Request failed",
  "method": "POST", "path": "/users", "duration": 23, "error": { ... } }

maskSensitiveData

Deep-masks fields whose name (case-insensitive) contains any of sensitiveFields, returning a new structure. Used internally by RequestLogger; exported for reuse.

import { maskSensitiveData } from '@spfn/core/middleware';

maskSensitiveData(
    { username: 'john', password: 'secret', apiKey: 'sk_live_x' },
    ['password', 'apiKey'],
);
// → { username: 'john', password: '***MASKED***', apiKey: '***MASKED***' }
  • Partial + case-insensitive: ['password'] masks userPassword, PASSWORD, etc.
  • Recursive: descends into nested objects and arrays.
  • Immutable: shallow-clones at each level; the input is untouched.
  • Circular-safe: repeated references become '[Circular]' (via an internal WeakSet).
  • Non-objects (null, primitives) are returned as-is.

Replacement token is the literal string '***MASKED***'.


rateLimit

Redis-backed fixed-window rate limiter. Three ways to apply it: attach per-route with .use([rateLimit({...})]); enable a global default for every route; or tag a route with a named policy the consuming app tunes centrally. The global default and policy tags use the named 'rateLimit' middleware, so routes opt out with .skip(['rateLimit']).

import { rateLimit, getClientIp } from '@spfn/core/middleware';

// 10 requests / minute per client IP (the default dimension)
route.post('/_auth/login')
    .use([rateLimit({ limit: 10, windowMs: 60_000 })])
    .handler(/* ... */);

// limit on more than one dimension — the strictest wins
route.post('/_auth/codes')
    .use([rateLimit({
        limit: 5,
        windowMs: 60_000,
        by: (c) => [getClientIp(c)],
    })])
    .handler(/* ... */);

Options (RateLimitOptions)

Field Type Default Notes
limit number Max requests per window, applied to each dimension.
windowMs number Window length in milliseconds.
scope string `${method} ${routePath}` Counter-key namespace; defaults to per-route.
by (c) => (Dimension | null | undefined)[] [getClientIp(c)] Identity dimensions; each non-empty value is counted separately, strictest wins. A Dimension is a string (uses limit) or { key, limit? } to give that dimension its own limit.

Per-dimension limits let one limiter be loose on IP and tight on account/target — so a shared NAT isn't throttled as one user while a single account stays protected:

rateLimit({
    limit: 5,                 // default for dimensions without their own limit
    windowMs: 60_000,
    by: (c) => [
        { key: `ip:${getClientIp(c)}`, limit: 100 }, // loose per-IP floor
        accountKey(c),                               // tight per-account (uses limit: 5)
    ],
});

@spfn/auth ships byIpAndAccount() / byIpAndTarget() builders for exactly this on its login, register, and verification-code routes (per-account / per-target tight, per-IP loose), so distributed brute force and SMS-bombing are limited by the thing being attacked, not only by source IP. | failClosed | boolean | false | Reject with 429 when the cache is unavailable instead of allowing through. | | message | string | generic | 429 response message. |

Behavior

  • Atomic: counts via a single Lua INCR + PEXPIRE, so the expiry is never lost in a race between two requests.
  • Fail-open by default: when no cache is configured (or it is disabled), requests pass and a warning is logged — matching the proxy-guard nonce store's graceful degradation, so local dev without Redis still works. Set failClosed: true to reject instead.
  • On exceed: throws TooManyRequestsError (429) and sets a Retry-After header derived from the key's remaining TTL.
  • Storage: keys are ratelimit:{scope}:{dimension} in the shared cache.

IP trust caveat: getClientIp reads the leftmost X-Forwarded-For hop, which a client can spoof unless a trusted proxy overwrites it. For security-sensitive limits, pair the IP dimension with an account/target dimension rather than relying on IP alone.

Global default limiter

Turn on a default limiter for every route from one place — no per-route .use(). It is registered as the named 'rateLimit' middleware, so a route opts out with .skip(['rateLimit']). Disabled by default (mode: 'off').

export default defineServerConfig()
    .rateLimit({
        mode: 'on',
        default: { limit: 100, windowMs: 60_000 },
    })
    .routes(appRouter)
    .build();

Or via env: RATE_LIMIT_MODE=on, RATE_LIMIT_DEFAULT_LIMIT, RATE_LIMIT_DEFAULT_WINDOW_MS, RATE_LIMIT_FAIL_CLOSED. Health, SSE and WebSocket endpoints register outside the named-middleware pipeline, so they are always exempt.

Named policies — rateLimitPolicy(name, fallback)

Lets a package tag a sensitive route while the consuming app tunes the numbers centrally. The package ships the tag with a safe fallback; the app overrides it by name.

// in a package (e.g. @spfn/auth)
route.post('/_auth/login')
    .use([rateLimitPolicy('auth-login', { limit: 5, windowMs: 60_000 })])
    .handler(/* ... */);

// in the consuming app — tune every policy in one place
export default defineServerConfig()
    .rateLimit({
        policies: {
            'auth-login': { limit: 10, windowMs: 60_000 },
        },
    })
    .routes(appRouter)
    .build();
  • Resolution: configured policy (by name) wins, shallow-merged over the fallback; with no config the fallback applies, so the route is protected out of the box.
  • Layered: the tag registers under a distinct name (rateLimit:<name>) and does not skip the global default, so a tagged route gets both — the global per-IP floor (when enabled) and this policy's own bucket; whichever is stricter trips first. Opt out of the floor on a route with .skip(['rateLimit']).
  • Shared bucket: a policy's counter scope defaults to its name, so every route sharing a policy shares one bucket (e.g. all OAuth start routes share oauth-start). It also keeps the key from colliding with the global default's per-route scope. Pass an explicit scope to override.
  • Policies are registered at server boot whether or not the global default is enabled.

Limitations & operational notes

Read these before relying on rate limiting as a security control:

  • Default identity is the client IP. A limiter with no by keys only on getClientIp(c), so a distributed attacker (many source IPs) isn't stopped per-account and a shared NAT/CGNAT can be throttled as one user. For account/target protection (credential stuffing, OTP/SMS-bombing) add a by dimension returning a stable account/target key alongside the IP — ideally with a per-dimension limit (loose IP, tight account). The bundled @spfn/auth login/register/code routes already do this via byIpAndAccount() / byIpAndTarget().
  • getClientIp trusts forwarded headers. Behind a verified proxy (proxy-guard) it uses the real client IP; otherwise it reads X-Forwarded-For/X-Real-IP, which a direct client can spoof (rotate to bypass, or pin to a victim to DoS them). With no forwarding header it falls back to the TCP peer address (Node adapter), and only to the literal 'unknown' when even that is unavailable (non-Node runtime). Still: enable the global default behind a proxy that sets a trustworthy client IP, since the header — when present — is taken on trust.
  • Fail-open by default. When the cache (Redis) is down, limiters pass requests through. Set RATE_LIMIT_FAIL_CLOSED=true to reject instead — this now applies to both the global default and named policy tags (a tag may still opt back to fail-open with failClosed: false).
  • Counter scopes differ by layer. The global default is keyed per-route (${method} ${routePath}); a named policy is keyed by its name, so routes sharing a policy share one bucket. Pass an explicit scope to change either.
  • The SSE token endpoint is not exempt. Only the SSE stream, WebSocket, and health endpoints (registered outside the named-middleware pipeline) bypass the global default; POST /events/token runs config.middlewares, so it receives the limiter too.

Pitfalls & anti-patterns

  • ErrorHandler goes on app.onError(), never app.use(). It returns an (err, c) => Response error hook, not a (c, next) middleware. Putting it in use() (or config.middlewares / config.use) will not catch errors.
  • Don't double-register under defineServerConfig. The server auto-applies both. Only use the raw app.use(RequestLogger()) / app.onError(ErrorHandler()) calls on a bare Hono app. To change RequestLogger options under SPFN, set middleware.logger: false and add your own via config.use.
  • config.middleware.onError is the only ErrorHandler option the builder forwards. includeStack / enableLogging are not configurable through defineServerConfig — they use defaults. Need them tuned? Build the Hono app manually.
  • RequestLogger must run before ErrorHandler for requestId to appear in OnErrorContext. The auto-config order (logger → … → onError) already guarantees this; preserve it if wiring manually (app.use(RequestLogger()) then app.onError(...)).
  • Excluded paths get no request ID. excludePaths short-circuits before c.set('requestId'), so handlers on /health etc. read undefined.
  • These are not "named middleware." They have no .skip() name and cannot be skipped per-route via the route DSL. Route-level skip applies only to NamedMiddleware created with defineMiddleware (@spfn/core/route). To exclude paths from logging, use excludePaths, not .skip().
  • sensitiveFields matches by substring. A field named tokenize is masked because it contains token. Choose field names with that in mind.
  • Don't import from @spfn/core. No root barrel exists; use @spfn/core/middleware.
  • RequestLoggerConfig is deprecated — alias of RequestLoggerOptions.

Complete example (raw Hono)

import { Hono } from 'hono';
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
import { NotFoundError } from '@spfn/core/errors';

const app = new Hono();

// 1. RequestLogger first — assigns requestId, times every request
app.use('*', RequestLogger({
    excludePaths: ['/health'],
    slowRequestThreshold: 500,
}));

app.get('/users/:id', async (c) =>
{
    const requestId = c.get('requestId');
    const user = await findUser(c.req.param('id'));

    if (!user)
    {
        throw new NotFoundError({ message: 'User not found', resource: 'User' });
    }

    return c.json({ requestId, user });
});

// 2. ErrorHandler last — onError hook catches everything above
app.onError(ErrorHandler({
    includeStack: process.env.NODE_ENV !== 'production',
    onError: (err, ctx) => notify(ctx),
}));

export default app;

Under SPFN, the equivalent is just defineServerConfig().routes(appRouter).build() — both middleware are added automatically.


Types reference

interface ErrorHandlerOptions {
    includeStack?: boolean;   // default: env.NODE_ENV !== 'production'
    enableLogging?: boolean;  // default: true
    onError?: (err: Error, context: OnErrorContext) => Promise<void> | void;
}

interface OnErrorContext {
    statusCode: number;
    path: string;
    method: string;
    requestId?: string;
    timestamp: string;
    userId?: string;
    request: { headers: Record<string, string>; query: Record<string, string> };
}

interface RequestLoggerOptions {
    excludePaths?: string[];        // default: ['/health', '/ping', '/favicon.ico']
    sensitiveFields?: string[];     // default: ['password','token','apiKey','secret','authorization']
    slowRequestThreshold?: number;  // default: 1000 (ms)
}

type RequestLoggerConfig = RequestLoggerOptions; // @deprecated

function maskSensitiveData(obj: any, sensitiveFields: string[], seen?: WeakSet<object>): any;
  • @spfn/core/routedefineMiddleware / defineMiddlewareFactory (custom named middleware), route-level .use() / .skip() wiring and execution order.
  • @spfn/core/dbTransactional() route middleware (auto commit/rollback).
  • @spfn/core/serverdefineServerConfig / config.middleware, which auto-registers RequestLogger + ErrorHandler.
  • @spfn/core/errorsSerializableError and the built-in error classes that ErrorHandler serializes.
  • @spfn/core/logger — the logger both middleware write to.