UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

197 lines 8.21 kB
/** * Rate limit hooks for @beignet/core/server */ import { AppError, httpErrors } from "../../errors/index.js"; import { AuthUnauthorizedError, } from "../../ports/index.js"; import { createProviderInstrumentation, } from "../../providers/index.js"; import { resolveTrustedClientIp, } from "../trusted-proxy.js"; function resolveClientIp(req, ipSource) { if (ipSource === "none") { return undefined; } return resolveTrustedClientIp(req, ipSource); } function hasTrustedProxyClientIp(config) { if (!config) return false; return Boolean(config.clientIp); } function formatContractNames(names) { return names.map((name) => `"${name}"`).join(", "); } function ipSourceConfigurationError(contractNames) { return new Error(`createRateLimitHooks(...) has no client IP source configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` + `Set trustedProxy.clientIp or ipSource to a header source written by a trusted edge, ` + `for example "x-forwarded-for-last", "x-forwarded-for-first", "x-real-ip", "cf-connecting-ip", or a custom function, ` + `or "none" to explicitly accept one shared unknown-client bucket per contract.`); } function namespaceContractKey(contractName, key) { const encodedContractName = contractName .replaceAll("%", "%25") .replaceAll(":", "%3A"); return `contract:${encodedContractName}:${key}`; } function emitUserKey(contractName, userId) { return namespaceContractKey(contractName, `user:${userId}`); } function emitIpKey(contractName, ip) { return namespaceContractKey(contractName, `ip:${ip}`); } function defaultRateLimitKey(args, getClientIp) { const { contractName, ctx, req, scope } = args; if (scope === "user") { if (ctx.actor?.type !== "user" || !ctx.actor.id) { throw new AuthUnauthorizedError(); } return emitUserKey(contractName, ctx.actor.id); } if (scope === "ip") { const ip = getClientIp(req) || "unknown"; return emitIpKey(contractName, ip); } return namespaceContractKey(contractName, "global"); } function defaultEarlyRateLimitKey(args, getClientIp) { if (args.scope === "ip") { const ip = getClientIp(args.req) || "unknown"; return emitIpKey(args.contractName, ip); } return namespaceContractKey(args.contractName, "global"); } async function enforceRateLimit(ports, args) { const result = await ports.rateLimit.hit({ key: args.key, limit: args.limit, windowSec: args.windowSec, }); if (result.allowed) { return; } // App ports may carry an `instrumentation` or `devtools` sink alongside the // typed rate-limit port; the helper resolves them at runtime. const instrumentation = createProviderInstrumentation(ports, { providerName: "rate-limit", watcher: "rateLimit", }); instrumentation.custom({ name: "rateLimit.denied", label: "Rate limit denied", summary: `Rate limit denied for ${args.key}`, details: { key: args.key, scope: args.scope, limit: args.limit, windowSec: args.windowSec, }, }); throw new AppError(httpErrors.TooManyRequests, { scope: args.scope, retryAfterSeconds: result.retryAfterSeconds, resetAt: result.resetAt?.toISOString() ?? null, }, "Rate limit exceeded", result.retryAfterSeconds !== null ? { headers: { "Retry-After": String(result.retryAfterSeconds) } } : undefined); } /** * Create metadata-driven rate-limit hooks. * * The hook reads `contract.metadata.rateLimit`. Global and IP-scoped limits run * in `onRequest` before context creation; user-scoped limits run in * `beforeHandle` after route hooks have resolved identity and `ctx.actor` is * available. Default keys include the contract name so unrelated contracts do * not share counters. A user-scoped limit without a resolved user actor fails * with `AuthUnauthorizedError` instead of falling back to a global bucket. * Exceeded limits throw the framework `TooManyRequests` app error * with `scope`, `retryAfterSeconds`, and `resetAt` details, and the 429 * response carries a `Retry-After` header when the limiter reports a reset * time. The bucket key is * never sent to clients; denials emit a `rateLimit.denied` instrumentation * event that carries the key for operators. * * `ip`-scoped limits require an explicit `trustedProxy.clientIp`, `ipSource`, * or custom `earlyKey`: the hook's `validate` phase fails `createServer(...)` * startup when a registered contract declares an `ip` scope without one, * instead of silently collapsing all clients into one shared bucket. * Contracts added later through `server.route(...)` are not visible to * `validate`, so enforcing an `ip`-scoped limit without a client-IP source * throws the same configuration error at request time as a backstop. Pass * `ipSource: "none"` to explicitly opt in to one unknown-client bucket per * contract. * * @param options - Optional key builders and client-IP source. * @returns A server hook backed by `ctx.ports.rateLimit`. */ export function createRateLimitHooks(options = {}) { const { ipSource, trustedProxy } = options; const getClientIp = (req, requestInfo, contractName) => { if (ipSource !== undefined) { return resolveClientIp(req, ipSource); } if (trustedProxy !== undefined) { if (hasTrustedProxyClientIp(trustedProxy) && trustedProxy) { return resolveTrustedClientIp(req, trustedProxy.clientIp); } throw ipSourceConfigurationError(formatContractNames([contractName])); } if (requestInfo?.clientIpTrusted) { return requestInfo.clientIp; } throw ipSourceConfigurationError(formatContractNames([contractName])); }; return { name: "rate-limit", validate: ({ contracts, trustedProxy: serverTrustedProxy }) => { if (ipSource !== undefined || hasTrustedProxyClientIp(trustedProxy !== undefined ? trustedProxy : serverTrustedProxy) || options.earlyKey) { return; } const ipScoped = contracts .filter((contract) => contract.metadata?.rateLimit?.scope === "ip") .map((contract) => contract.name); if (ipScoped.length === 0) { return; } throw ipSourceConfigurationError(formatContractNames(ipScoped)); }, onRequest: async ({ contract, ports, req, requestInfo }) => { const rlMeta = contract.metadata?.rateLimit; if (!rlMeta) { return undefined; } const scope = rlMeta.scope ?? "global"; if (scope === "user") { return undefined; } const key = options.earlyKey?.({ req, scope }) ?? defaultEarlyRateLimitKey({ req, scope, contractName: contract.name }, (r) => getClientIp(r, requestInfo, contract.name)); await enforceRateLimit(ports, { key, limit: rlMeta.max, windowSec: rlMeta.windowSec, scope, }); return undefined; }, beforeHandle: async ({ ctx, contract, req, requestInfo }) => { const rlMeta = contract.metadata?.rateLimit; if (!rlMeta) { return undefined; } const scope = rlMeta.scope ?? "global"; if (scope !== "user") { return undefined; } const key = options.key?.({ ctx, req, scope }) ?? defaultRateLimitKey({ ctx, req, scope, contractName: contract.name }, (r) => getClientIp(r, requestInfo, contract.name)); await enforceRateLimit(ctx.ports, { key, limit: rlMeta.max, windowSec: rlMeta.windowSec, scope, }); return undefined; }, }; } //# sourceMappingURL=rate-limit.js.map