Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
export type {
RateLimitContext,
RateLimitKeySelector,
ResolvedKey,
} from "./selector.js";
export { resolveRateLimitKey } from "./selector.js";

View File

@@ -0,0 +1,85 @@
# selector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves the rate-limit bucket key for a per-request frame, given the
route-declared (or host-defaulted) `RateLimitKeySelector`. The
selector kind controls how the middleware fans the per-route budget
across distinct callers — `"tenant"` shares the bucket across every
principal in a tenant, `"principal"` isolates by authenticated user,
`"ip"` falls back to the network identity, and the `"custom"` form
gives the route author a free-form projection.
## Features
- Discriminated union `RateLimitKeySelector =
"tenant" | "principal" | "ip" | { kind: "custom"; select }`.
- Default selection (`selector === undefined`) prefers
`"principal"` when authenticated, else `"ip"` — matching design doc
§6 step 4a.
- Strict-mode fallback to the literal `"anon"` when the chosen kind's
source is `null` (no tenant, no principal, no IP). The middleware
never crashes; the limit just shares a single bucket across every
truly-anonymous caller.
- Returned `ResolvedKey` carries both the bucket key string AND the
`kind` label so the OTel counter can attach a stable
`key_kind` attribute without re-parsing the key.
## Usage: Basic
```ts
import { resolveRateLimitKey } from "@suaveplan/api-rate-limit/selector";
const { key, kind } = resolveRateLimitKey(
{ tenantId: "t1", principal: { id: "alice" }, ip: "10.0.0.1", request: new Request("https://h/") },
"principal"
);
// → { key: "principal:alice", kind: "principal" }
```
## Usage: Advanced
A custom selector that buckets per `(tenant, route-action-token)` so
two different mutations on the same route share fan-in but distinct
buckets:
```ts
import { resolveRateLimitKey, type RateLimitKeySelector } from "@suaveplan/api-rate-limit/selector";
const custom: RateLimitKeySelector = {
kind: "custom",
select: (ctx) => {
const u = new URL(ctx.request.url);
const action = u.searchParams.get("action") ?? "default";
return `${ctx.tenantId ?? "anon"}:${action}`;
},
};
const r = resolveRateLimitKey(
{ tenantId: "t1", principal: null, ip: "10.0.0.1", request: new Request("https://h/q?action=write") },
custom
);
// → { key: "t1:write", kind: "custom" }
```
## API Reference
- `type RateLimitKeySelector =
"tenant" | "principal" | "ip" |
{ readonly kind: "custom"; readonly select: (ctx: RateLimitContext) => string }`
- `interface RateLimitContext { tenantId: string | null; principal: { id: string } | null; ip: string | null; request: Request }`
- `interface ResolvedKey { readonly key: string; readonly kind: "tenant" | "principal" | "ip" | "custom" }`
- `function resolveRateLimitKey(ctx, selector?): ResolvedKey`
## Implementation Notes
The function is pure — it does not consult environment, the clock, or
the store. That makes it trivial to unit-test the default selector
chain against a structural `RateLimitContext` literal without
spinning up a full request pipeline. The custom selector kind is
deliberately the only variant that takes a callback; everything else
is data so the selector is plain JSON-serialisable for
`host.json`-level overrides.

View File

@@ -0,0 +1,101 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { RateLimitContext, RateLimitKeySelector } from "./selector.js";
import { resolveRateLimitKey } from "./selector.js";
function buildCtx(overrides: Partial<RateLimitContext> = {}): RateLimitContext {
return {
tenantId: "tenantId" in overrides ? overrides.tenantId! : "t1",
principal:
"principal" in overrides ? overrides.principal! : { id: "alice" },
ip: "ip" in overrides ? overrides.ip! : "10.0.0.1",
request: overrides.request ?? new Request("https://h/route"),
};
}
describe("resolveRateLimitKey", () => {
it("tenant key isolates buckets", { timeout: 5000 }, () => {
const a = resolveRateLimitKey(
buildCtx({ tenantId: "tenant-a" }),
"tenant"
);
const b = resolveRateLimitKey(
buildCtx({ tenantId: "tenant-b" }),
"tenant"
);
expect(a.key).toBe("tenant:tenant-a");
expect(b.key).toBe("tenant:tenant-b");
expect(a.kind).toBe("tenant");
expect(a.key).not.toBe(b.key);
});
it("principal key projects principal id", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(
buildCtx({ principal: { id: "bob" } }),
"principal"
);
expect(r).toEqual({ key: "principal:bob", kind: "principal" });
});
it("ip key projects ip", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(buildCtx({ ip: "192.0.2.10" }), "ip");
expect(r).toEqual({ key: "ip:192.0.2.10", kind: "ip" });
});
it("default falls back to IP without principal", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(
buildCtx({ principal: null, ip: "10.0.0.2" })
);
expect(r).toEqual({ key: "ip:10.0.0.2", kind: "ip" });
});
it("default uses principal when present", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(buildCtx());
expect(r).toEqual({ key: "principal:alice", kind: "principal" });
});
it("strict-mode anon fallback when tenant/principal/ip missing", {
timeout: 5000,
}, () => {
expect(
resolveRateLimitKey(buildCtx({ tenantId: null }), "tenant")
).toEqual({ key: "tenant:anon", kind: "tenant" });
expect(
resolveRateLimitKey(buildCtx({ principal: null }), "principal")
).toEqual({ key: "principal:anon", kind: "principal" });
expect(resolveRateLimitKey(buildCtx({ ip: null }), "ip")).toEqual({
key: "ip:anon",
kind: "ip",
});
});
it("custom selector receives the request and produces a key", {
timeout: 5000,
}, () => {
const selector: RateLimitKeySelector = {
kind: "custom",
select: (c) => {
const u = new URL(c.request.url);
return `${c.tenantId}:${u.pathname}`;
},
};
const r = resolveRateLimitKey(
buildCtx({ request: new Request("https://h/foo") }),
selector
);
expect(r).toEqual({ key: "t1:/foo", kind: "custom" });
});
it("RateLimitKeySelector is the documented union", {
timeout: 5000,
}, () => {
expectTypeOf<RateLimitKeySelector>().toEqualTypeOf<
| "tenant"
| "principal"
| "ip"
| {
readonly kind: "custom";
readonly select: (ctx: RateLimitContext) => string;
}
>();
});
});

View File

@@ -0,0 +1,71 @@
/**
* Per-route key-selector resolution.
*
* The selector kind is part of the route's `rateLimit` option block
* (declared by the plugin author) plus an optional host-level default.
* Resolution happens once per request, before the middleware consults
* the store.
*/
/**
* Minimal subset of `RequestContext` the selector consults. Declared
* locally so this package does not need a runtime peer-dep on
* `@suaveplan/api-types/plugin-context`. Anything broader can be
* stripped at the call site.
*/
export interface RateLimitContext {
readonly tenantId: string | null;
readonly principal: { readonly id: string } | null;
readonly ip: string | null;
readonly request: Request;
}
/** Discriminated union of supported selector kinds. */
export type RateLimitKeySelector =
| "tenant"
| "principal"
| "ip"
| {
readonly kind: "custom";
readonly select: (ctx: RateLimitContext) => string;
};
/** Result of selector resolution. */
export interface ResolvedKey {
readonly key: string;
readonly kind: "tenant" | "principal" | "ip" | "custom";
}
/**
* Resolve the rate-limit key for a request. When `selector` is
* `undefined`, the default is `"principal"` if a principal is present
* else `"ip"`. Strict-mode: if even the IP fallback is unavailable
* (no `X-Forwarded-For`, no remote address), the key falls back to
* the literal string `"anon"` so the bucket logic never crashes.
*/
export function resolveRateLimitKey(
ctx: RateLimitContext,
selector?: RateLimitKeySelector
): ResolvedKey {
const effective: RateLimitKeySelector =
selector ?? (ctx.principal !== null ? "principal" : "ip");
if (typeof effective === "object") {
return { key: effective.select(ctx), kind: "custom" };
}
switch (effective) {
case "tenant":
return {
key: `tenant:${ctx.tenantId ?? "anon"}`,
kind: "tenant",
};
case "principal":
return {
key: `principal:${ctx.principal?.id ?? "anon"}`,
kind: "principal",
};
case "ip":
return { key: `ip:${ctx.ip ?? "anon"}`, kind: "ip" };
}
}