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,5 @@
export {
type RateLimitPluginOptionsInput,
type RateLimitPluginOptionsResolved,
RateLimitPluginOptionsSchema,
} from "./options.js";

View File

@@ -0,0 +1,87 @@
# `schemas/options` — Zod schema for `createRateLimitPlugin` options
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates the caller-supplied `RateLimitPluginOptions` at the
boundary and fills in defaults for every optional field, producing the
resolved `RateLimitPluginConfig` the plugin's `onInit` lifecycle and
the `RateLimitService` rely on. The schema is the single source of
truth for what shape `createRateLimitPlugin` accepts — the factory
calls `safeParse(...)` and throws `RateLimitPluginError(INVALID_INPUT)`
with the underlying `ZodError` attached as `cause` on validation
failure, so callers see a typed error rather than a runtime crash.
## Features
- `defaultLimit` / `defaultWindowMs` mandatory positive integers.
- `routes`, `tenants`, `ipWhitelist` default to empty arrays.
- `keyResolver` defaults to `${ip}|${tenantId ?? "_"}` — composable
isolation between IPs and tenants out of the box.
- `headerStyle` enum: `"x-rate-limit"` (default), `"rfc-9238"`,
`"both"`.
- `emitRetryAfter` defaults to `true`.
- `storage` is a custom Zod runtime check accepting any
`RateLimitStorage`-shaped object — caller-supplied adapters
(Redis, SurrealDB) pass through untouched.
- `.strict()` so typos in option names fail loudly instead of
silently doing nothing.
## Usage: Basic
```ts
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
```
## Usage: Advanced
```ts
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
const result = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: [
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
],
tenants: [
{ tenantId: "enterprise", multiplier: 10 },
{ tenantId: "internal", bypass: true },
],
ipWhitelist: ["127.0.0.1"],
headerStyle: "both",
});
if (!result.success) console.error(result.error.issues);
```
## API Reference
- `RateLimitPluginOptionsSchema` — Zod schema instance. Parses
`RateLimitPluginOptions` (input) into `RateLimitPluginConfig`
(output) with every default filled in.
- `RateLimitPluginOptionsInput` — `z.input<typeof
RateLimitPluginOptionsSchema>` — what callers supply.
- `RateLimitPluginOptionsResolved` — `z.output<typeof
RateLimitPluginOptionsSchema>` — what the plugin receives.
## Implementation Notes
- `keyResolver` uses `z.custom<RateLimitKeyResolver>` — functions
cannot be expressed as a JSON-Schema-compatible Zod shape; the
custom check enforces `typeof === "function"`.
- `storage` and `logger` are runtime references; the schema preserves
them as-is via `z.custom` rather than attempting to validate their
internal shape.
- `defaults` declared inline so `RateLimitPluginOptionsResolved` is a
total interface — no `undefined` fallbacks in the consumer.
- The schema is `.strict()` to catch typos like
`defaultLimti: 100` at the boundary.

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RateLimitPluginOptionsSchema } from "./options.js";
describe("RateLimitPluginOptionsSchema", () => {
it("fills defaults for every optional field", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
expect(result.routes).toEqual([]);
expect(result.tenants).toEqual([]);
expect(result.ipWhitelist).toEqual([]);
expect(typeof result.keyResolver).toBe("function");
expect(result.headerStyle).toBe("x-rate-limit");
expect(result.emitRetryAfter).toBe(true);
});
it("default keyResolver renders ${ip}|${tenant}", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 1,
});
expect(
result.keyResolver({
ip: "127.0.0.1",
tenantId: undefined,
path: "/",
method: "GET",
})
).toBe("127.0.0.1|_");
expect(
result.keyResolver({
ip: "1.2.3.4",
tenantId: "ent",
path: "/",
method: "GET",
})
).toBe("1.2.3.4|ent");
});
it("rejects negative defaultLimit", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: -1,
defaultWindowMs: 1_000,
});
expect(result.success).toBe(false);
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 1,
defaultWindowMs: 1,
// @ts-expect-error — typos must fail loudly
defaultLimti: 999,
});
expect(result.success).toBe(false);
});
it("passes through caller-supplied keyResolver", { timeout: 5000 }, () => {
const custom = () => "k";
const result = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 1,
keyResolver: custom,
});
expect(result.keyResolver).toBe(custom);
});
it("validates RouteRule shape and rejects empty path", {
timeout: 5000,
}, () => {
const bad = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 1,
defaultWindowMs: 1,
routes: [{ path: "", limit: 1, windowMs: 1 }],
});
expect(bad.success).toBe(false);
});
it("validates TenantRule shape", { timeout: 5000 }, () => {
const good = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 1,
defaultWindowMs: 1,
tenants: [
{ tenantId: "t1", multiplier: 2 },
{ tenantId: "t2", bypass: true },
],
});
expect(good.success).toBe(true);
});
it("accepts all three header styles", { timeout: 5000 }, () => {
for (const headerStyle of [
"x-rate-limit",
"rfc-9238",
"both",
] as const) {
const r = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 1,
headerStyle,
});
expect(r.headerStyle).toBe(headerStyle);
}
});
});

View File

@@ -0,0 +1,65 @@
import z from "zod";
import type {
RateLimitKeyResolver,
RateLimitPluginConfig,
RateLimitPluginOptions,
} from "../types/types.js";
/**
* Default key resolver — `${ip}|${tenantId ?? "_"}`. Uses pipe rather
* than colon to avoid colliding with `keyPrefix:storage-key` joins in
* the underlying storage adapter.
*/
const defaultKeyResolver: RateLimitKeyResolver = (input) =>
`${input.ip}|${input.tenantId ?? "_"}`;
const RouteRuleSchema = z.object({
path: z.string().min(1),
method: z.string().min(1).optional(),
limit: z.number().int().positive(),
windowMs: z.number().int().positive(),
id: z.string().min(1).optional(),
});
const TenantRuleSchema = z.object({
tenantId: z.string().min(1),
multiplier: z.number().positive().optional(),
bypass: z.boolean().optional(),
});
/**
* Zod schema for the plugin's caller-facing options. Applies defaults
* for every optional field so the resolved config is fully populated.
*
* `storage` and `logger` are skipped because their values are runtime
* objects, not serialisable — they are passed through the schema
* untouched via `z.custom`.
*/
export const RateLimitPluginOptionsSchema = z
.object({
defaultLimit: z.number().int().nonnegative(),
defaultWindowMs: z.number().int().positive(),
routes: z.array(RouteRuleSchema).default([]),
tenants: z.array(TenantRuleSchema).default([]),
ipWhitelist: z.array(z.string().min(1)).default([]),
keyResolver: z
.custom<RateLimitKeyResolver>((v) => typeof v === "function")
.default(() => defaultKeyResolver),
headerStyle: z
.enum(["x-rate-limit", "rfc-9238", "both"])
.default("x-rate-limit"),
emitRetryAfter: z.boolean().default(true),
storage: z.custom<RateLimitPluginConfig["storage"]>().optional(),
logger: z.custom<RateLimitPluginConfig["logger"]>().optional(),
})
.strict() satisfies z.ZodType<
RateLimitPluginConfig,
RateLimitPluginOptions
>;
export type RateLimitPluginOptionsInput = z.input<
typeof RateLimitPluginOptionsSchema
>;
export type RateLimitPluginOptionsResolved = z.output<
typeof RateLimitPluginOptionsSchema
>;