@daloyjs/core 0.35.2 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +22 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +223 -1
  8. package/dist/app.js +358 -8
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
@@ -0,0 +1,212 @@
1
+ /**
2
+ * AsyncAPI 3.0 document generator for WebSocket surfaces.
3
+ *
4
+ * Built-in, dependency-free, and a deliberate mirror of the OpenAPI 3.1
5
+ * generator in `./openapi.ts`: it turns every `app.ws()` route into an
6
+ * AsyncAPI **channel** (the socket address + path parameters) and one or more
7
+ * **operations** (`receive` for client→server messages, `send` for
8
+ * server→client messages). The RFC 6455 stack and its CSWSH defenses finally
9
+ * get a contract/doc artifact, extending the contract-first story past HTTP.
10
+ *
11
+ * If a message schema exposes a `toJSONSchema()` method (Zod 4, Valibot, ...)
12
+ * we use it; otherwise we emit a permissive `{}` placeholder rather than fail
13
+ * — docs and tooling still work, just with looser types for that payload.
14
+ */
15
+ import { openapiToYAML } from "./openapi.js";
16
+ /**
17
+ * Convert a Standard Schema to JSON Schema for an AsyncAPI message payload.
18
+ *
19
+ * Mirrors the OpenAPI generator's permissive strategy: use `toJSONSchema()`
20
+ * when the schema exposes it (Zod 4, Valibot, ...), and otherwise fall back to
21
+ * a permissive `{}` so generation never throws on an unconvertible schema.
22
+ *
23
+ * @param schema - The Standard Schema to convert, or `undefined`.
24
+ * @returns A JSON-Schema-shaped object, or `undefined` when no schema given.
25
+ */
26
+ function toPayloadSchema(schema) {
27
+ if (!schema)
28
+ return undefined;
29
+ const anySchema = schema;
30
+ if (typeof anySchema.toJSONSchema === "function") {
31
+ try {
32
+ return anySchema.toJSONSchema();
33
+ }
34
+ catch {
35
+ /* fall through to permissive placeholder */
36
+ }
37
+ }
38
+ return {};
39
+ }
40
+ /**
41
+ * Derive a stable, unique channel/operation key from a WebSocket path.
42
+ *
43
+ * Strips the leading slash, drops `:param` / `{param}` markers, and camelCases
44
+ * the remaining segments (`/chat/:room/feed` → `chatRoomFeed`). Falls back to
45
+ * `root` for `/`. Collisions are de-duplicated by the caller.
46
+ *
47
+ * @param path - The registered WebSocket route path.
48
+ * @returns A safe identifier base for AsyncAPI keys.
49
+ */
50
+ function pathToKey(path) {
51
+ const segments = path
52
+ .split("/")
53
+ .map((s) => s.replace(/[:{}]/g, ""))
54
+ .filter((s) => s.length > 0);
55
+ if (segments.length === 0)
56
+ return "root";
57
+ return segments
58
+ .map((seg, i) => {
59
+ const clean = seg.replace(/[^A-Za-z0-9]+/g, " ").trim();
60
+ const parts = clean.split(/\s+/).filter(Boolean);
61
+ return parts
62
+ .map((part, j) => i === 0 && j === 0
63
+ ? part.charAt(0).toLowerCase() + part.slice(1)
64
+ : part.charAt(0).toUpperCase() + part.slice(1))
65
+ .join("");
66
+ })
67
+ .join("");
68
+ }
69
+ /**
70
+ * Extract `:param` names from a WebSocket route path in declaration order.
71
+ *
72
+ * @param path - The registered WebSocket route path.
73
+ * @returns The list of path-parameter names (without the leading colon).
74
+ */
75
+ function extractParams(path) {
76
+ const names = [];
77
+ for (const match of path.matchAll(/:([A-Za-z0-9_]+)/g)) {
78
+ names.push(match[1]);
79
+ }
80
+ return names;
81
+ }
82
+ /**
83
+ * Generate an AsyncAPI 3.0 document from a registered {@link App}'s WebSocket
84
+ * routes.
85
+ *
86
+ * Every `app.ws()` route becomes one channel (its address + path parameters)
87
+ * and one or more operations:
88
+ *
89
+ * - a `receive` operation for client→server messages — payload taken from the
90
+ * route's `meta.receive` schema, falling back to the handler's
91
+ * `request.body` schema (the same schema used for payload-size checks).
92
+ * - a `send` operation for server→client messages — emitted only when the
93
+ * route declares a `meta.send` schema.
94
+ *
95
+ * The output is a plain JSON-serializable object: hand it to AsyncAPI Studio,
96
+ * write it to disk for codegen, or serve it from a route. When the app has no
97
+ * WebSocket routes the document still validates, with empty `channels` and
98
+ * `operations` maps.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * import { generateAsyncAPI } from "@daloyjs/core/asyncapi";
103
+ * import { writeFileSync } from "node:fs";
104
+ *
105
+ * const doc = generateAsyncAPI(app, {
106
+ * info: { title: "Realtime API", version: "1.0.0" },
107
+ * servers: { production: { host: "api.example.com", protocol: "wss" } },
108
+ * });
109
+ * writeFileSync("./generated/asyncapi.json", JSON.stringify(doc, null, 2));
110
+ * ```
111
+ *
112
+ * @param app - The application whose WebSocket routes are documented.
113
+ * @param options - Document metadata and optional named servers.
114
+ * @returns A JSON-serializable AsyncAPI 3.0 document.
115
+ * @since 0.37.0
116
+ */
117
+ export function generateAsyncAPI(app, options) {
118
+ const channels = {};
119
+ const operations = {};
120
+ const messages = {};
121
+ const usedKeys = new Set();
122
+ const entries = app.webSocketRoutes.list();
123
+ for (const entry of entries) {
124
+ const path = entry.path;
125
+ const meta = entry.handler.meta;
126
+ // Derive a unique channel key (operationId override > path-derived slug).
127
+ let key = meta?.operationId ?? pathToKey(path);
128
+ if (usedKeys.has(key)) {
129
+ let suffix = 2;
130
+ while (usedKeys.has(`${key}${suffix}`))
131
+ suffix += 1;
132
+ key = `${key}${suffix}`;
133
+ }
134
+ usedKeys.add(key);
135
+ const address = path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
136
+ const paramNames = extractParams(path);
137
+ const channelMessages = {};
138
+ // Inbound: a WebSocket route can always receive client messages, so a
139
+ // `receive` operation is always emitted (permissive payload when no schema).
140
+ const receiveSchema = meta?.receive ?? entry.handler.request?.body;
141
+ const receiveMsgKey = `${key}Receive`;
142
+ messages[receiveMsgKey] = {
143
+ name: receiveMsgKey,
144
+ title: `${key} inbound message`,
145
+ payload: toPayloadSchema(receiveSchema) ?? {},
146
+ };
147
+ channelMessages.receiveMessage = {
148
+ $ref: `#/components/messages/${receiveMsgKey}`,
149
+ };
150
+ operations[receiveMsgKey] = {
151
+ action: "receive",
152
+ channel: { $ref: `#/channels/${key}` },
153
+ ...(meta?.summary ? { summary: meta.summary } : {}),
154
+ ...(meta?.description ? { description: meta.description } : {}),
155
+ ...(meta?.tags ? { tags: meta.tags.map((t) => ({ name: t })) } : {}),
156
+ messages: [{ $ref: `#/channels/${key}/messages/receiveMessage` }],
157
+ };
158
+ // Outbound: only emitted when the route declares an outbound schema.
159
+ const sendSchema = meta?.send;
160
+ if (sendSchema) {
161
+ const sendMsgKey = `${key}Send`;
162
+ messages[sendMsgKey] = {
163
+ name: sendMsgKey,
164
+ title: `${key} outbound message`,
165
+ payload: toPayloadSchema(sendSchema) ?? {},
166
+ };
167
+ channelMessages.sendMessage = {
168
+ $ref: `#/components/messages/${sendMsgKey}`,
169
+ };
170
+ operations[sendMsgKey] = {
171
+ action: "send",
172
+ channel: { $ref: `#/channels/${key}` },
173
+ ...(meta?.summary ? { summary: meta.summary } : {}),
174
+ ...(meta?.tags ? { tags: meta.tags.map((t) => ({ name: t })) } : {}),
175
+ messages: [{ $ref: `#/channels/${key}/messages/sendMessage` }],
176
+ };
177
+ }
178
+ const parameters = {};
179
+ for (const name of paramNames) {
180
+ parameters[name] = { description: `Path parameter \`${name}\`.` };
181
+ }
182
+ channels[key] = {
183
+ address,
184
+ ...(meta?.summary ? { summary: meta.summary } : {}),
185
+ ...(meta?.description ? { description: meta.description } : {}),
186
+ ...(paramNames.length ? { parameters } : {}),
187
+ messages: channelMessages,
188
+ };
189
+ }
190
+ return {
191
+ asyncapi: "3.0.0",
192
+ info: options.info,
193
+ ...(options.servers ? { servers: options.servers } : {}),
194
+ channels,
195
+ operations,
196
+ components: { messages },
197
+ };
198
+ }
199
+ /**
200
+ * Serialize an AsyncAPI document to YAML.
201
+ *
202
+ * Thin alias over the dependency-free YAML 1.2 emitter shared with the
203
+ * OpenAPI generator ({@link openapiToYAML}) — AsyncAPI and OpenAPI documents
204
+ * are both plain JSON-compatible objects, so the same emitter applies.
205
+ *
206
+ * @param doc - The AsyncAPI document produced by {@link generateAsyncAPI}.
207
+ * @returns The document rendered as a YAML string.
208
+ * @since 0.37.0
209
+ */
210
+ export function asyncapiToYAML(doc) {
211
+ return openapiToYAML(doc);
212
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Adaptive auto-ban (fail2ban-style) middleware. Where {@link "./middleware.js".loginThrottle}
3
+ * only protects credential-entry routes, {@link autoBan} generalizes the idea
4
+ * into a reusable, escalating, decaying ban primitive: when a single client
5
+ * trips too many "suspicious" responses (by default `401` / `403` / `429`) inside
6
+ * a rolling window, it is temporarily banned. Repeat offenders earn
7
+ * exponentially longer bans, and the record decays away once the client goes
8
+ * quiet — so a one-off burst is forgiven while a persistent attacker is shut out
9
+ * for progressively longer.
10
+ *
11
+ * The middleware is dependency-free and runtime-portable. It observes outgoing
12
+ * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
+ * status produced by *any* later middleware or handler, not just its own) and
14
+ * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
15
+ * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
+ * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
+ * implementation for multi-instance deployments.
18
+ *
19
+ * @module
20
+ * @since 0.37.0
21
+ */
22
+ import type { BaseContext, Hooks } from "./types.js";
23
+ /**
24
+ * One client's auto-ban bookkeeping. A record tracks the current strike count
25
+ * inside the rolling strike window, when that window expires, the timestamp the
26
+ * client is banned until (`0` when not banned), and how many bans the client
27
+ * has accumulated while the record has stayed alive (drives escalation).
28
+ *
29
+ * @since 0.37.0
30
+ */
31
+ export interface AutoBanRecord {
32
+ /** Suspicious responses seen inside the current strike window. */
33
+ strikes: number;
34
+ /** Epoch ms at which the current strike window resets (strikes decay to 0). */
35
+ strikeExpiresMs: number;
36
+ /** Epoch ms the client is banned until; `0` when the client is not banned. */
37
+ bannedUntilMs: number;
38
+ /** Total bans issued while this record stayed alive; drives escalation. */
39
+ banCount: number;
40
+ }
41
+ /**
42
+ * Pluggable backend for {@link autoBan}, mirroring the `rateLimit()` store
43
+ * contract. Implementations persist one {@link AutoBanRecord} per key and must
44
+ * treat an entry whose `ttlMs` has elapsed as absent (so bans and escalation
45
+ * decay automatically). The built-in default is in-memory and single-process;
46
+ * back it with Redis (or another shared store) for multi-instance deployments.
47
+ *
48
+ * @since 0.37.0
49
+ */
50
+ export interface AutoBanStore {
51
+ /** Resolve the current record for `key`, or `undefined` when none/expired. */
52
+ get(key: string): Promise<AutoBanRecord | undefined>;
53
+ /**
54
+ * Persist `record` for `key`, expiring it after `ttlMs`. Implementations
55
+ * should set the backing TTL so an idle key is reclaimed automatically.
56
+ */
57
+ set(key: string, record: AutoBanRecord, ttlMs: number): Promise<void>;
58
+ /** Forget `key` entirely (e.g. an operator manually lifting a ban). */
59
+ delete(key: string): Promise<void>;
60
+ }
61
+ /**
62
+ * Emitted via {@link AutoBanOptions.onBan} when a client crosses the strike
63
+ * threshold and a (possibly escalated) ban is issued. Useful for alerting,
64
+ * structured audit logging, or feeding an external denylist.
65
+ *
66
+ * @since 0.37.0
67
+ */
68
+ export interface AutoBanEvent {
69
+ /** The store key the ban applies to (group prefix + client identity). */
70
+ key: string;
71
+ /** How many times this client has been banned while its record stayed alive. */
72
+ banCount: number;
73
+ /** The duration of this ban in milliseconds. */
74
+ banDurationMs: number;
75
+ /** Epoch ms the client is banned until. */
76
+ bannedUntilMs: number;
77
+ }
78
+ /**
79
+ * Emitted via {@link AutoBanOptions.onStrike} every time a suspicious response
80
+ * is attributed to a client (before any resulting ban). Lets callers observe
81
+ * pressure building without waiting for the ban itself.
82
+ *
83
+ * @since 0.37.0
84
+ */
85
+ export interface AutoBanStrikeEvent {
86
+ /** The store key the strike applies to. */
87
+ key: string;
88
+ /** The strike count after recording this strike, inside the current window. */
89
+ strikes: number;
90
+ /** The response status that triggered the strike. */
91
+ status: number;
92
+ }
93
+ /**
94
+ * Configuration for {@link autoBan}. Every field is optional except that the
95
+ * middleware must be able to identify clients: supply a {@link keyGenerator} or
96
+ * set {@link trustProxyHeaders} (otherwise construction throws, to avoid
97
+ * accidentally banning every client through a shared `"global"` bucket).
98
+ *
99
+ * @since 0.37.0
100
+ */
101
+ export interface AutoBanOptions {
102
+ /** Rolling strike window in ms; strikes older than this decay. Default: 10 minutes. */
103
+ windowMs?: number;
104
+ /** Suspicious responses inside `windowMs` that trigger a ban. Default: 5. */
105
+ maxStrikes?: number;
106
+ /** Base ban duration in ms (first offence). Default: 15 minutes. */
107
+ banMs?: number;
108
+ /** Hard cap on an escalated ban duration in ms. Default: 24 hours. */
109
+ maxBanMs?: number;
110
+ /**
111
+ * Double the ban duration on each repeat ban while the record stays alive
112
+ * (`banMs`, `2×banMs`, `4×banMs`, … capped at `maxBanMs`). Default: `true`.
113
+ * When `false`, every ban lasts exactly `banMs`.
114
+ */
115
+ escalate?: boolean;
116
+ /**
117
+ * Response status codes treated as suspicious. Default: `[401, 403, 429]`.
118
+ * Add `400` / `422` to also count request-validation failures, but be aware
119
+ * those can include honest client mistakes.
120
+ */
121
+ watchStatuses?: readonly number[];
122
+ /**
123
+ * Status used for the ban rejection: `429` (default, carries `Retry-After`)
124
+ * or `403`. `403` surfaces {@link AutoBanOptions.message}.
125
+ */
126
+ banStatus?: 403 | 429;
127
+ /**
128
+ * Derive the client identity from `ctx`, or `undefined` to skip the request
129
+ * (fail-open — never banned, never counted). Defaults to the proxy-header
130
+ * resolver when {@link trustProxyHeaders} is set.
131
+ */
132
+ keyGenerator?: (ctx: BaseContext<any, any>) => string | undefined;
133
+ /**
134
+ * Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
135
+ * default because those headers are client-spoofable unless every request
136
+ * reaches the app through a proxy chain you control.
137
+ */
138
+ trustProxyHeaders?: boolean;
139
+ /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
140
+ store?: AutoBanStore;
141
+ /**
142
+ * Share one ban store across every `autoBan()` mounted with the same
143
+ * `groupId`, so a client banned on one route group is banned on all of them.
144
+ * Default: `"auto-ban"`. Only meaningful for the in-memory default store.
145
+ */
146
+ groupId?: string;
147
+ /** Send `Retry-After` on a `429` ban rejection. Default: `true`. */
148
+ retryAfter?: boolean;
149
+ /** Message for the `403` ban variant. Default: `"Temporarily banned"`. */
150
+ message?: string;
151
+ /** Called when a ban is issued (alerting / audit / external denylist). */
152
+ onBan?: (event: AutoBanEvent) => void;
153
+ /** Called for every recorded strike, before any resulting ban. */
154
+ onStrike?: (event: AutoBanStrikeEvent) => void;
155
+ }
156
+ /**
157
+ * Test-only helper that clears the process-wide shared auto-ban stores. Not part
158
+ * of the documented public API.
159
+ *
160
+ * @internal
161
+ */
162
+ export declare function _resetAutoBanStoresForTests(): void;
163
+ /**
164
+ * Default in-memory {@link AutoBanStore}. Single-process only; entries are
165
+ * reclaimed lazily on access and opportunistically when the map grows large, so
166
+ * an idle attacker's record decays without an explicit timer.
167
+ *
168
+ * @since 0.37.0
169
+ */
170
+ export declare class MemoryAutoBanStore implements AutoBanStore {
171
+ private map;
172
+ /** {@inheritDoc AutoBanStore.get} */
173
+ get(key: string): Promise<AutoBanRecord | undefined>;
174
+ /** {@inheritDoc AutoBanStore.set} */
175
+ set(key: string, record: AutoBanRecord, ttlMs: number): Promise<void>;
176
+ /** {@inheritDoc AutoBanStore.delete} */
177
+ delete(key: string): Promise<void>;
178
+ }
179
+ /**
180
+ * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
181
+ * suspicious outgoing responses per client and temporarily bans repeat
182
+ * offenders; bans grow exponentially for persistent abuse and decay once the
183
+ * client goes quiet.
184
+ *
185
+ * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
186
+ * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
187
+ * a misconfiguration can never collapse every caller into one shared bucket and
188
+ * ban the whole world at once. A request the key generator cannot attribute is
189
+ * skipped (never counted, never banned).
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * import { autoBan } from "@daloyjs/core";
194
+ *
195
+ * // Five 401/403/429s within 10 min → 15 min ban, doubling for repeat offenders.
196
+ * app.use(autoBan({ trustProxyHeaders: true }));
197
+ * ```
198
+ *
199
+ * @param opts - Auto-ban configuration.
200
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
201
+ * @throws Error when neither `keyGenerator` nor `trustProxyHeaders` is provided,
202
+ * or when a numeric option is out of range.
203
+ * @since 0.37.0
204
+ */
205
+ export declare function autoBan(opts?: AutoBanOptions): Hooks;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Adaptive auto-ban (fail2ban-style) middleware. Where {@link "./middleware.js".loginThrottle}
3
+ * only protects credential-entry routes, {@link autoBan} generalizes the idea
4
+ * into a reusable, escalating, decaying ban primitive: when a single client
5
+ * trips too many "suspicious" responses (by default `401` / `403` / `429`) inside
6
+ * a rolling window, it is temporarily banned. Repeat offenders earn
7
+ * exponentially longer bans, and the record decays away once the client goes
8
+ * quiet — so a one-off burst is forgiven while a persistent attacker is shut out
9
+ * for progressively longer.
10
+ *
11
+ * The middleware is dependency-free and runtime-portable. It observes outgoing
12
+ * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
+ * status produced by *any* later middleware or handler, not just its own) and
14
+ * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
15
+ * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
+ * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
+ * implementation for multi-instance deployments.
18
+ *
19
+ * @module
20
+ * @since 0.37.0
21
+ */
22
+ import { ForbiddenError, TooManyRequestsError } from "./errors.js";
23
+ const DEFAULT_WINDOW_MS = 10 * 60_000;
24
+ const DEFAULT_MAX_STRIKES = 5;
25
+ const DEFAULT_BAN_MS = 15 * 60_000;
26
+ const DEFAULT_MAX_BAN_MS = 24 * 60 * 60_000;
27
+ const DEFAULT_WATCH_STATUSES = [401, 403, 429];
28
+ const DEFAULT_GROUP_ID = "auto-ban";
29
+ const STATE_KEY = "__autoBanKey";
30
+ const STATE_REJECTED = "__autoBanRejected";
31
+ /**
32
+ * Process-wide registry of shared in-memory stores keyed by `groupId`, so two
33
+ * `autoBan({ groupId })` mounts cooperate on one ban map.
34
+ *
35
+ * @internal
36
+ */
37
+ const SHARED_AUTO_BAN_STORES = new Map();
38
+ /**
39
+ * Test-only helper that clears the process-wide shared auto-ban stores. Not part
40
+ * of the documented public API.
41
+ *
42
+ * @internal
43
+ */
44
+ export function _resetAutoBanStoresForTests() {
45
+ SHARED_AUTO_BAN_STORES.clear();
46
+ }
47
+ /**
48
+ * Default in-memory {@link AutoBanStore}. Single-process only; entries are
49
+ * reclaimed lazily on access and opportunistically when the map grows large, so
50
+ * an idle attacker's record decays without an explicit timer.
51
+ *
52
+ * @since 0.37.0
53
+ */
54
+ export class MemoryAutoBanStore {
55
+ map = new Map();
56
+ /** {@inheritDoc AutoBanStore.get} */
57
+ async get(key) {
58
+ const entry = this.map.get(key);
59
+ if (!entry)
60
+ return undefined;
61
+ if (entry.expiresMs <= Date.now()) {
62
+ this.map.delete(key);
63
+ return undefined;
64
+ }
65
+ return entry.record;
66
+ }
67
+ /** {@inheritDoc AutoBanStore.set} */
68
+ async set(key, record, ttlMs) {
69
+ const now = Date.now();
70
+ this.map.set(key, { record, expiresMs: now + ttlMs });
71
+ if (this.map.size > 10_000) {
72
+ for (const [k, v] of this.map)
73
+ if (v.expiresMs <= now)
74
+ this.map.delete(k);
75
+ }
76
+ }
77
+ /** {@inheritDoc AutoBanStore.delete} */
78
+ async delete(key) {
79
+ this.map.delete(key);
80
+ }
81
+ }
82
+ function assertPositiveInteger(name, value) {
83
+ if (!Number.isInteger(value) || value <= 0) {
84
+ throw new Error(`autoBan(): ${name} must be a positive integer.`);
85
+ }
86
+ }
87
+ function forwardedKey(ctx) {
88
+ const forwarded = ctx.request.headers.get("x-forwarded-for");
89
+ const first = forwarded ? forwarded.split(",")[0].trim() : "";
90
+ if (first)
91
+ return first;
92
+ return ctx.request.headers.get("x-real-ip") ?? undefined;
93
+ }
94
+ /**
95
+ * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
96
+ * suspicious outgoing responses per client and temporarily bans repeat
97
+ * offenders; bans grow exponentially for persistent abuse and decay once the
98
+ * client goes quiet.
99
+ *
100
+ * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
101
+ * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
102
+ * a misconfiguration can never collapse every caller into one shared bucket and
103
+ * ban the whole world at once. A request the key generator cannot attribute is
104
+ * skipped (never counted, never banned).
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * import { autoBan } from "@daloyjs/core";
109
+ *
110
+ * // Five 401/403/429s within 10 min → 15 min ban, doubling for repeat offenders.
111
+ * app.use(autoBan({ trustProxyHeaders: true }));
112
+ * ```
113
+ *
114
+ * @param opts - Auto-ban configuration.
115
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
116
+ * @throws Error when neither `keyGenerator` nor `trustProxyHeaders` is provided,
117
+ * or when a numeric option is out of range.
118
+ * @since 0.37.0
119
+ */
120
+ export function autoBan(opts = {}) {
121
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
122
+ const maxStrikes = opts.maxStrikes ?? DEFAULT_MAX_STRIKES;
123
+ const banMs = opts.banMs ?? DEFAULT_BAN_MS;
124
+ const maxBanMs = opts.maxBanMs ?? DEFAULT_MAX_BAN_MS;
125
+ assertPositiveInteger("windowMs", windowMs);
126
+ assertPositiveInteger("maxStrikes", maxStrikes);
127
+ assertPositiveInteger("banMs", banMs);
128
+ assertPositiveInteger("maxBanMs", maxBanMs);
129
+ if (maxBanMs < banMs) {
130
+ throw new Error("autoBan(): maxBanMs must be >= banMs.");
131
+ }
132
+ const escalate = opts.escalate ?? true;
133
+ const banStatus = opts.banStatus ?? 429;
134
+ if (banStatus !== 403 && banStatus !== 429) {
135
+ throw new Error("autoBan(): banStatus must be 403 or 429.");
136
+ }
137
+ const retryAfter = opts.retryAfter !== false;
138
+ const message = opts.message ?? "Temporarily banned";
139
+ const watchStatuses = opts.watchStatuses ?? DEFAULT_WATCH_STATUSES;
140
+ if (watchStatuses.length === 0) {
141
+ throw new Error("autoBan(): watchStatuses must list at least one status code.");
142
+ }
143
+ for (const status of watchStatuses) {
144
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
145
+ throw new Error("autoBan(): watchStatuses must be integer HTTP status codes (100-599).");
146
+ }
147
+ }
148
+ const watch = new Set(watchStatuses);
149
+ if (!opts.keyGenerator && !opts.trustProxyHeaders) {
150
+ throw new Error("autoBan(): provide keyGenerator or set trustProxyHeaders so clients can be identified; " +
151
+ "otherwise every caller shares one bucket and a single offender would ban everyone.");
152
+ }
153
+ const keyOf = opts.keyGenerator ?? forwardedKey;
154
+ const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
155
+ let store;
156
+ if (opts.store) {
157
+ store = opts.store;
158
+ }
159
+ else {
160
+ let shared = SHARED_AUTO_BAN_STORES.get(groupId);
161
+ if (!shared) {
162
+ shared = new MemoryAutoBanStore();
163
+ SHARED_AUTO_BAN_STORES.set(groupId, shared);
164
+ }
165
+ store = shared;
166
+ }
167
+ const prefix = `${groupId}:`;
168
+ return {
169
+ async beforeHandle(ctx) {
170
+ const identity = keyOf(ctx);
171
+ if (identity === undefined)
172
+ return undefined;
173
+ const key = `${prefix}${identity}`;
174
+ const state = ctx.state;
175
+ state[STATE_KEY] = key;
176
+ const record = await store.get(key);
177
+ const now = Date.now();
178
+ if (record && record.bannedUntilMs > now) {
179
+ state[STATE_REJECTED] = true;
180
+ if (banStatus === 403)
181
+ throw new ForbiddenError(message);
182
+ const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
183
+ throw new TooManyRequestsError(retryAfter ? retry : undefined);
184
+ }
185
+ return undefined;
186
+ },
187
+ async onSend(res, ctx) {
188
+ if (!ctx)
189
+ return undefined;
190
+ const state = ctx.state;
191
+ // Never count the ban rejection we just produced — that would let an
192
+ // active ban perpetually re-arm itself.
193
+ if (state[STATE_REJECTED] === true)
194
+ return undefined;
195
+ const key = state[STATE_KEY];
196
+ if (key === undefined)
197
+ return undefined;
198
+ if (!watch.has(res.status))
199
+ return undefined;
200
+ const now = Date.now();
201
+ const record = await store.get(key);
202
+ const windowActive = record !== undefined && record.strikeExpiresMs > now;
203
+ let strikes = (windowActive ? record.strikes : 0) + 1;
204
+ let banCount = record?.banCount ?? 0;
205
+ let bannedUntilMs = record?.bannedUntilMs ?? 0;
206
+ const strikeExpiresMs = now + windowMs;
207
+ opts.onStrike?.({ key, strikes, status: res.status });
208
+ if (strikes >= maxStrikes) {
209
+ banCount += 1;
210
+ const duration = escalate
211
+ ? Math.min(maxBanMs, banMs * 2 ** (banCount - 1))
212
+ : banMs;
213
+ bannedUntilMs = now + duration;
214
+ strikes = 0;
215
+ opts.onBan?.({ key, banCount, banDurationMs: duration, bannedUntilMs });
216
+ }
217
+ const ttlMs = Math.max(strikeExpiresMs, bannedUntilMs) - now;
218
+ await store.set(key, { strikes, strikeExpiresMs, bannedUntilMs, banCount }, ttlMs);
219
+ return undefined;
220
+ },
221
+ };
222
+ }