@beignet/core
Version:
Core framework primitives for Beignet
197 lines • 8.21 kB
JavaScript
/**
* 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