Initial Commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
RateLimitContext,
|
||||
RateLimitKeySelector,
|
||||
ResolvedKey,
|
||||
} from "./selector.js";
|
||||
export { resolveRateLimitKey } from "./selector.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user