@daloyjs/core 0.36.0 → 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 +21 -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 +144 -1
  8. package/dist/app.js +208 -1
  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
package/dist/index.js CHANGED
@@ -6,8 +6,9 @@ export { _resetInsecureDefaultsLogForTests } from "./app.js";
6
6
  export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
7
7
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
8
8
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
9
- export { HttpError, BadRequestError, ValidationError, NotFoundError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
9
+ export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
10
10
  export { validate, isStandardSchema } from "./schema.js";
11
+ export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
11
12
  export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
12
13
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
13
14
  export { etag } from "./etag.js";
@@ -19,6 +20,18 @@ export { assertTemporalClaims, TemporalClaimError, } from "./time-claims.js";
19
20
  export { every, some, except } from "./combine.js";
20
21
  export { ipRestriction } from "./ip-restriction.js";
21
22
  export { fetchGuard, SsrfBlockedError } from "./fetch-guard.js";
23
+ export { resilientFetch, CircuitBreaker, CircuitOpenError, FetchTimeoutError, } from "./fetch-resilience.js";
24
+ export { createWebhookSender, MemoryWebhookDeadLetterSink, } from "./webhook-delivery.js";
25
+ export { Scheduler, CronParseError, parseCron, nextCronRun, } from "./scheduler.js";
26
+ export { clientCertAuth, setClientCertificate, getClientCertificate, normalizePeerCertificate, parseForwardedClientCert, } from "./mtls.js";
27
+ export { signMessage, signRequest, verifyMessage, verifyRequest, httpSignatureAuth, contentDigest, verifyContentDigest, DEFAULT_SIGNATURE_LABEL, DEFAULT_MAX_SIGNATURE_AGE_SECONDS, DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS, } from "./http-signatures.js";
28
+ export { autoBan, MemoryAutoBanStore, _resetAutoBanStoresForTests, } from "./auto-ban.js";
29
+ export { botGuard, GOOGLEBOT, BINGBOT, WELL_KNOWN_BOTS } from "./bot-guard.js";
30
+ export { ipReputation, urlFeed } from "./ip-reputation.js";
31
+ export { geoBlock } from "./geo-block.js";
32
+ export { concurrencyLimit } from "./concurrency-limit.js";
33
+ export { requestDecompression, decompressRequestBody, DecompressionBombError, UnsupportedContentEncodingError, MalformedCompressedBodyError, _resetRequestDecompressionProbeForTests, } from "./request-decompression.js";
34
+ export { waf } from "./waf.js";
22
35
  export { safeRedirect, OpenRedirectBlockedError } from "./safe-redirect.js";
23
36
  export { loadShedding, LOAD_SHEDDING_MARKER } from "./load-shedding.js";
24
37
  export { defineConfig, ConfigValidationError } from "./config.js";
@@ -28,6 +41,10 @@ export { sseStream, sseResponse, ndjsonStream, ndjsonResponse, } from "./streami
28
41
  export { httpBearerScheme, httpBasicScheme, apiKeyScheme, oauth2Scheme, openIdConnectScheme, REQUIRE_PAYLOAD_AUTH_EXTENSION, securitySchemeRequiresPayloadAuth, toOpenAPISecurityScheme, } from "./security-schemes.js";
29
42
  export { discriminator, discriminatedUnion } from "./discriminator.js";
30
43
  export { session, rotateSession, signValue, verifySignedValue, MemorySessionStore, SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
44
+ export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTests, } from "./idempotency.js";
45
+ export { responseCache, MemoryResponseCacheStore, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
46
+ export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
47
+ export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
31
48
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
32
49
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
33
50
  export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
@@ -0,0 +1,198 @@
1
+ /**
2
+ * IP reputation / dynamic denylist feed for {@link Hooks}. Where
3
+ * {@link "./ip-restriction.js".ipRestriction} enforces a *static* allow/deny
4
+ * list compiled once at construction, {@link ipReputation} wires **pluggable,
5
+ * periodically-refreshed abuse feeds** (Tor exit lists, Spamhaus DROP,
6
+ * cloud-abuse ranges, your own threat intel) into the request path without
7
+ * rebuilding the matcher.
8
+ *
9
+ * Design goals:
10
+ *
11
+ * 1. **Pluggable feeds** — any {@link IpReputationFeed} that yields IP / CIDR
12
+ * strings. {@link urlFeed} ships for the common case (fetch a newline /
13
+ * Spamhaus-DROP-style list over HTTP), but a feed can be backed by anything.
14
+ * 2. **Periodic refresh** — the denylist is reloaded on an `unref`'d timer so a
15
+ * long-lived abuse range eventually expires and new ranges are picked up,
16
+ * without a redeploy.
17
+ * 3. **Fail-open** — a denylist is *additive* defense-in-depth, never the only
18
+ * gate. If a feed fails to load (initial or refresh), traffic is **not**
19
+ * blocked: the last-known-good list is kept (or an empty list if nothing has
20
+ * loaded yet). A feed outage must never take the whole app down.
21
+ *
22
+ * The middleware is dependency-free and runtime-portable; it reuses the
23
+ * SSRF-grade CIDR matcher from `ipRestriction()`. {@link urlFeed}'s default
24
+ * transport is the platform `fetch`; pass your own for non-standard runtimes or
25
+ * to layer SSRF protection.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { ipReputation, urlFeed } from "@daloyjs/core";
30
+ *
31
+ * const reputation = ipReputation({
32
+ * trustProxyHeaders: true,
33
+ * feeds: [
34
+ * urlFeed("https://www.spamhaus.org/drop/drop.txt", { name: "spamhaus-drop" }),
35
+ * urlFeed("https://check.torproject.org/torbulkexitlist", { name: "tor-exit" }),
36
+ * ],
37
+ * refreshIntervalMs: 60 * 60_000, // hourly
38
+ * });
39
+ *
40
+ * app.use(reputation.hooks);
41
+ * // On shutdown: reputation.stop();
42
+ * ```
43
+ *
44
+ * @module
45
+ * @since 0.37.0
46
+ */
47
+ import type { BaseContext, Hooks } from "./types.js";
48
+ /**
49
+ * A pluggable source of abusive IP / CIDR entries. Implementations return the
50
+ * raw list each refresh; parsing, validation, and de-duplication are handled by
51
+ * {@link ipReputation}.
52
+ *
53
+ * @since 0.37.0
54
+ */
55
+ export interface IpReputationFeed {
56
+ /** Human-readable feed name, surfaced in {@link IpReputationMatch.feed}. */
57
+ name: string;
58
+ /**
59
+ * Fetch the current entries. May return IP addresses or CIDR ranges as
60
+ * strings; invalid entries are skipped (never throw for junk lines). Honour
61
+ * the {@link AbortSignal} when provided.
62
+ *
63
+ * @param signal - Abort signal tied to the refresh timeout.
64
+ * @returns The current IP / CIDR entries.
65
+ */
66
+ fetch(signal?: AbortSignal): Promise<readonly string[]>;
67
+ }
68
+ /**
69
+ * Details of a request that matched the reputation denylist. Passed to
70
+ * {@link IpReputationOptions.onMatch}.
71
+ *
72
+ * @since 0.37.0
73
+ */
74
+ export interface IpReputationMatch {
75
+ /** The resolved client IP. */
76
+ ip: string;
77
+ /** Names of the feeds whose entries matched. */
78
+ feeds: readonly string[];
79
+ }
80
+ /**
81
+ * Options for {@link ipReputation}.
82
+ *
83
+ * @since 0.37.0
84
+ */
85
+ export interface IpReputationOptions {
86
+ /** One or more abuse feeds to merge into the denylist. Required, non-empty. */
87
+ feeds: readonly IpReputationFeed[];
88
+ /**
89
+ * How often to reload every feed, in ms. Default 1 hour. Set `0` to disable
90
+ * the timer and refresh only manually via {@link IpReputationController.refresh}.
91
+ */
92
+ refreshIntervalMs?: number;
93
+ /**
94
+ * Per-refresh timeout for each feed `fetch`, in ms. Default 30 s. A feed that
95
+ * exceeds it is aborted and treated as a (fail-open) refresh failure.
96
+ */
97
+ fetchTimeoutMs?: number;
98
+ /**
99
+ * Load the feeds immediately at construction. Default `true`. When `false`,
100
+ * the first load happens on the first timer tick (or manual `refresh()`), so
101
+ * early requests see an empty denylist (fail-open).
102
+ */
103
+ loadOnStart?: boolean;
104
+ /**
105
+ * Custom client-IP resolver. Overrides {@link IpReputationOptions.trustProxyHeaders}.
106
+ * Defaults to failing open (no IP → not blocked).
107
+ */
108
+ resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
109
+ /**
110
+ * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
111
+ * enable behind a trusted proxy that overwrites these headers.
112
+ */
113
+ trustProxyHeaders?: boolean;
114
+ /**
115
+ * `"block"` (default) throws a {@link ForbiddenError} on a match; `"log"`
116
+ * only invokes {@link IpReputationOptions.onMatch} and lets the request
117
+ * continue (monitor mode).
118
+ */
119
+ mode?: "block" | "log";
120
+ /** Detail string for the `403` problem+json. Default `"IP address not permitted"`. */
121
+ message?: string;
122
+ /** Called whenever a request IP is on the denylist (in both modes). */
123
+ onMatch?: (match: IpReputationMatch) => void;
124
+ /**
125
+ * Called when a feed fails to load or refresh. The denylist keeps its
126
+ * last-known-good entries (fail-open). Use it to surface feed-health metrics.
127
+ */
128
+ onError?: (error: unknown, feedName: string) => void;
129
+ }
130
+ /**
131
+ * A running IP-reputation guard. Pass {@link IpReputationController.hooks} to
132
+ * `app.use()`, drive refreshes manually with {@link IpReputationController.refresh},
133
+ * and release the timer on shutdown with {@link IpReputationController.stop}.
134
+ *
135
+ * @since 0.37.0
136
+ */
137
+ export interface IpReputationController {
138
+ /** The middleware hooks to register via `app.use(...)`. */
139
+ hooks: Hooks;
140
+ /**
141
+ * Force an immediate reload of every feed. Resolves once all feeds have
142
+ * settled (failures are swallowed per the fail-open contract).
143
+ */
144
+ refresh(): Promise<void>;
145
+ /** Stop the periodic-refresh timer. Idempotent. */
146
+ stop(): void;
147
+ /** Resolves after the first load attempt completes (success or fail-open). */
148
+ readonly ready: Promise<void>;
149
+ /** Current number of compiled denylist entries across all feeds. */
150
+ readonly size: number;
151
+ /** Test whether an IP is currently on the denylist (no side effects). */
152
+ has(ip: string): boolean;
153
+ }
154
+ /**
155
+ * Options for {@link urlFeed}.
156
+ *
157
+ * @since 0.37.0
158
+ */
159
+ export interface UrlFeedOptions {
160
+ /** Feed name. Defaults to the URL. */
161
+ name?: string;
162
+ /**
163
+ * Custom `fetch` implementation. Defaults to the platform `fetch`. Provide an
164
+ * SSRF-guarded fetch or a non-standard-runtime client here.
165
+ */
166
+ fetchImpl?: typeof fetch;
167
+ /** Extra request headers (e.g. an API token for a commercial feed). */
168
+ headers?: Record<string, string>;
169
+ }
170
+ /**
171
+ * Build an {@link IpReputationFeed} that fetches a newline-delimited IP / CIDR
172
+ * list over HTTP. Handles the Spamhaus-DROP-style `<cidr> ; <annotation>`
173
+ * format and `#` / `;` / `//` comment lines. Lines that aren't valid IPs/CIDRs
174
+ * are skipped by {@link ipReputation}, so a partially-malformed feed still loads
175
+ * its good entries.
176
+ *
177
+ * @param url - The feed URL.
178
+ * @param opts - Optional feed name, custom `fetch`, and request headers.
179
+ * @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
180
+ * @since 0.37.0
181
+ */
182
+ export declare function urlFeed(url: string, opts?: UrlFeedOptions): IpReputationFeed;
183
+ /**
184
+ * IP reputation / dynamic denylist middleware. Merges one or more pluggable
185
+ * abuse feeds into a periodically-refreshed denylist and rejects (or logs)
186
+ * requests from listed IPs, reusing the `ipRestriction()` CIDR matcher.
187
+ *
188
+ * Fail-open by design: a feed that cannot be loaded never blocks traffic — the
189
+ * last-known-good list is retained (empty until the first successful load). An
190
+ * unresolvable client IP is also treated as not-listed.
191
+ *
192
+ * @param opts - Reputation configuration; `feeds` must be non-empty.
193
+ * @returns An {@link IpReputationController} whose `hooks` go to `app.use(...)`.
194
+ * @throws Error when `feeds` is empty, `mode` is invalid, or a numeric option
195
+ * is out of range.
196
+ * @since 0.37.0
197
+ */
198
+ export declare function ipReputation(opts: IpReputationOptions): IpReputationController;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * IP reputation / dynamic denylist feed for {@link Hooks}. Where
3
+ * {@link "./ip-restriction.js".ipRestriction} enforces a *static* allow/deny
4
+ * list compiled once at construction, {@link ipReputation} wires **pluggable,
5
+ * periodically-refreshed abuse feeds** (Tor exit lists, Spamhaus DROP,
6
+ * cloud-abuse ranges, your own threat intel) into the request path without
7
+ * rebuilding the matcher.
8
+ *
9
+ * Design goals:
10
+ *
11
+ * 1. **Pluggable feeds** — any {@link IpReputationFeed} that yields IP / CIDR
12
+ * strings. {@link urlFeed} ships for the common case (fetch a newline /
13
+ * Spamhaus-DROP-style list over HTTP), but a feed can be backed by anything.
14
+ * 2. **Periodic refresh** — the denylist is reloaded on an `unref`'d timer so a
15
+ * long-lived abuse range eventually expires and new ranges are picked up,
16
+ * without a redeploy.
17
+ * 3. **Fail-open** — a denylist is *additive* defense-in-depth, never the only
18
+ * gate. If a feed fails to load (initial or refresh), traffic is **not**
19
+ * blocked: the last-known-good list is kept (or an empty list if nothing has
20
+ * loaded yet). A feed outage must never take the whole app down.
21
+ *
22
+ * The middleware is dependency-free and runtime-portable; it reuses the
23
+ * SSRF-grade CIDR matcher from `ipRestriction()`. {@link urlFeed}'s default
24
+ * transport is the platform `fetch`; pass your own for non-standard runtimes or
25
+ * to layer SSRF protection.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { ipReputation, urlFeed } from "@daloyjs/core";
30
+ *
31
+ * const reputation = ipReputation({
32
+ * trustProxyHeaders: true,
33
+ * feeds: [
34
+ * urlFeed("https://www.spamhaus.org/drop/drop.txt", { name: "spamhaus-drop" }),
35
+ * urlFeed("https://check.torproject.org/torbulkexitlist", { name: "tor-exit" }),
36
+ * ],
37
+ * refreshIntervalMs: 60 * 60_000, // hourly
38
+ * });
39
+ *
40
+ * app.use(reputation.hooks);
41
+ * // On shutdown: reputation.stop();
42
+ * ```
43
+ *
44
+ * @module
45
+ * @since 0.37.0
46
+ */
47
+ import { ForbiddenError } from "./errors.js";
48
+ import { compileCidrMatcher, matchesMatcher, parseIp, } from "./ip-restriction.js";
49
+ const DEFAULT_REFRESH_MS = 60 * 60_000;
50
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
51
+ const DEFAULT_MESSAGE = "IP address not permitted";
52
+ /**
53
+ * Split a single feed line into its IP / CIDR token, stripping comments and the
54
+ * trailing annotations common to abuse feeds (e.g. Spamhaus DROP's
55
+ * `1.2.3.0/24 ; SBL123`). Returns `undefined` for comment-only / blank lines.
56
+ *
57
+ * @internal
58
+ */
59
+ function parseFeedLine(line) {
60
+ let s = line.trim();
61
+ if (!s || s.startsWith("#") || s.startsWith(";") || s.startsWith("//")) {
62
+ return undefined;
63
+ }
64
+ // Cut inline comments / annotations after the address token.
65
+ const cut = s.search(/[\s;#]/);
66
+ if (cut !== -1)
67
+ s = s.slice(0, cut);
68
+ return s || undefined;
69
+ }
70
+ /**
71
+ * Build an {@link IpReputationFeed} that fetches a newline-delimited IP / CIDR
72
+ * list over HTTP. Handles the Spamhaus-DROP-style `<cidr> ; <annotation>`
73
+ * format and `#` / `;` / `//` comment lines. Lines that aren't valid IPs/CIDRs
74
+ * are skipped by {@link ipReputation}, so a partially-malformed feed still loads
75
+ * its good entries.
76
+ *
77
+ * @param url - The feed URL.
78
+ * @param opts - Optional feed name, custom `fetch`, and request headers.
79
+ * @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
80
+ * @since 0.37.0
81
+ */
82
+ export function urlFeed(url, opts = {}) {
83
+ const name = opts.name ?? url;
84
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
85
+ return {
86
+ name,
87
+ async fetch(signal) {
88
+ if (typeof doFetch !== "function") {
89
+ throw new Error("urlFeed: no fetch implementation available on this runtime. Pass options.fetchImpl.");
90
+ }
91
+ const res = await doFetch(url, {
92
+ signal,
93
+ headers: opts.headers,
94
+ redirect: "follow",
95
+ });
96
+ if (!res.ok) {
97
+ throw new Error(`urlFeed: ${name} responded ${res.status}.`);
98
+ }
99
+ const text = await res.text();
100
+ const out = [];
101
+ for (const line of text.split("\n")) {
102
+ const token = parseFeedLine(line);
103
+ if (token !== undefined)
104
+ out.push(token);
105
+ }
106
+ return out;
107
+ },
108
+ };
109
+ }
110
+ function forwardedIpResolver(ctx) {
111
+ const headers = ctx.request.headers;
112
+ const forwarded = headers.get("x-forwarded-for");
113
+ if (forwarded) {
114
+ const first = forwarded.split(",")[0]?.trim();
115
+ if (first)
116
+ return first;
117
+ }
118
+ return ctx.request.headers.get("x-real-ip") ?? undefined;
119
+ }
120
+ function noIpResolver(_ctx) {
121
+ return undefined;
122
+ }
123
+ /**
124
+ * IP reputation / dynamic denylist middleware. Merges one or more pluggable
125
+ * abuse feeds into a periodically-refreshed denylist and rejects (or logs)
126
+ * requests from listed IPs, reusing the `ipRestriction()` CIDR matcher.
127
+ *
128
+ * Fail-open by design: a feed that cannot be loaded never blocks traffic — the
129
+ * last-known-good list is retained (empty until the first successful load). An
130
+ * unresolvable client IP is also treated as not-listed.
131
+ *
132
+ * @param opts - Reputation configuration; `feeds` must be non-empty.
133
+ * @returns An {@link IpReputationController} whose `hooks` go to `app.use(...)`.
134
+ * @throws Error when `feeds` is empty, `mode` is invalid, or a numeric option
135
+ * is out of range.
136
+ * @since 0.37.0
137
+ */
138
+ export function ipReputation(opts) {
139
+ if (!opts.feeds || opts.feeds.length === 0) {
140
+ throw new Error("ipReputation(): at least one feed must be provided.");
141
+ }
142
+ const mode = opts.mode ?? "block";
143
+ if (mode !== "block" && mode !== "log") {
144
+ throw new Error('ipReputation(): mode must be "block" or "log".');
145
+ }
146
+ const refreshIntervalMs = opts.refreshIntervalMs ?? DEFAULT_REFRESH_MS;
147
+ if (!Number.isInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
148
+ throw new Error("ipReputation(): refreshIntervalMs must be a non-negative integer.");
149
+ }
150
+ const fetchTimeoutMs = opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
151
+ if (!Number.isInteger(fetchTimeoutMs) || fetchTimeoutMs <= 0) {
152
+ throw new Error("ipReputation(): fetchTimeoutMs must be a positive integer.");
153
+ }
154
+ const message = opts.message ?? DEFAULT_MESSAGE;
155
+ const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
156
+ // Last-known-good compiled denylist, one entry per feed so a single feed's
157
+ // failed refresh doesn't drop the others.
158
+ let compiled = opts.feeds.map((f) => ({ name: f.name, v4: [], v6: [] }));
159
+ const compileFeed = (name, entries) => {
160
+ const v4 = [];
161
+ const v6 = [];
162
+ for (const entry of entries) {
163
+ let matcher;
164
+ try {
165
+ matcher = compileCidrMatcher(entry);
166
+ }
167
+ catch {
168
+ continue; // skip junk lines; a malformed feed still loads its good rows
169
+ }
170
+ (matcher.family === 4 ? v4 : v6).push(matcher);
171
+ }
172
+ return { name, v4, v6 };
173
+ };
174
+ const refreshOne = async (feed, index) => {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
177
+ if (typeof timer === "object" && typeof timer.unref === "function")
178
+ timer.unref();
179
+ try {
180
+ const entries = await feed.fetch(controller.signal);
181
+ compiled[index] = compileFeed(feed.name, entries);
182
+ }
183
+ catch (err) {
184
+ // Fail-open: keep the previous compiled entries for this feed.
185
+ opts.onError?.(err, feed.name);
186
+ }
187
+ finally {
188
+ clearTimeout(timer);
189
+ }
190
+ };
191
+ const refresh = async () => {
192
+ await Promise.all(opts.feeds.map((feed, i) => refreshOne(feed, i)));
193
+ };
194
+ const matchingFeeds = (ip) => {
195
+ const parsed = parseIp(ip);
196
+ if (!parsed)
197
+ return [];
198
+ const hits = [];
199
+ for (const feed of compiled) {
200
+ const list = parsed.family === 4 ? feed.v4 : feed.v6;
201
+ if (list.some((m) => matchesMatcher(parsed, m)))
202
+ hits.push(feed.name);
203
+ }
204
+ return hits;
205
+ };
206
+ let stopped = false;
207
+ let interval;
208
+ if (refreshIntervalMs > 0) {
209
+ interval = setInterval(() => {
210
+ void refresh();
211
+ }, refreshIntervalMs);
212
+ if (typeof interval === "object" && typeof interval.unref === "function") {
213
+ interval.unref();
214
+ }
215
+ }
216
+ const ready = opts.loadOnStart === false ? Promise.resolve() : refresh();
217
+ return {
218
+ hooks: {
219
+ beforeHandle(ctx) {
220
+ const ip = resolveIp(ctx);
221
+ if (!ip)
222
+ return undefined; // fail-open on unresolved IP
223
+ const feeds = matchingFeeds(ip);
224
+ if (feeds.length === 0)
225
+ return undefined;
226
+ opts.onMatch?.({ ip, feeds });
227
+ if (mode === "block")
228
+ throw new ForbiddenError(message);
229
+ return undefined;
230
+ },
231
+ },
232
+ refresh,
233
+ stop() {
234
+ if (stopped)
235
+ return;
236
+ stopped = true;
237
+ if (interval !== undefined)
238
+ clearInterval(interval);
239
+ },
240
+ get ready() {
241
+ return ready;
242
+ },
243
+ get size() {
244
+ let total = 0;
245
+ for (const feed of compiled)
246
+ total += feed.v4.length + feed.v6.length;
247
+ return total;
248
+ },
249
+ has(ip) {
250
+ return matchingFeeds(ip).length > 0;
251
+ },
252
+ };
253
+ }
package/dist/jwk.d.ts CHANGED
@@ -35,6 +35,21 @@ export interface JwkOptions {
35
35
  * when `jwks` is not a URL.
36
36
  */
37
37
  fetchTtlSeconds?: number;
38
+ /**
39
+ * Grace window (seconds) during which a *previously fetched* JWKS keeps
40
+ * being served if a TTL-expiry refresh fails (network error, non-2xx,
41
+ * or malformed body). Measured from the last successful fetch, on top of
42
+ * {@link fetchTtlSeconds}; a transient IdP blip therefore does not fail
43
+ * every request while a perfectly valid cached key set is in hand. The
44
+ * first fetch is never eligible (there is no last-good set to fall back
45
+ * to), so an unreachable IdP at boot still rejects. Tokens are always
46
+ * cryptographically verified and `exp`-checked regardless. Set to `0` to
47
+ * disable and fail closed the moment the TTL lapses. Default `3600` (1h).
48
+ * Ignored when `jwks` is not a URL.
49
+ *
50
+ * @since 0.41.0
51
+ */
52
+ maxStaleSeconds?: number;
38
53
  /**
39
54
  * Optional `fetch` implementation override (mainly for tests). Defaults
40
55
  * to global `fetch`.
package/dist/jwk.js CHANGED
@@ -71,7 +71,7 @@ function findJwkByKid(jwks, kid) {
71
71
  }
72
72
  return undefined;
73
73
  }
74
- function makeJwksLoader(source, fetchImpl, ttlSeconds) {
74
+ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
75
75
  if (typeof source === "string") {
76
76
  if (!source.startsWith("https://")) {
77
77
  throw new Error("jwk(): jwks URL must be https:// — refusing plaintext JWKS source.");
@@ -100,6 +100,20 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds) {
100
100
  cache = { jwks: body, fetchedAt: Date.now() };
101
101
  return body;
102
102
  }
103
+ catch (err) {
104
+ // Stale-while-error: a TTL-expiry refresh that fails must not take
105
+ // down all auth when a last-good JWKS is still within the grace
106
+ // window. Public verification keys rotate slowly; tokens remain
107
+ // cryptographically verified and exp-checked either way. The first
108
+ // fetch (no cache) always rethrows, so an unreachable IdP at boot
109
+ // still fails closed.
110
+ if (cache &&
111
+ maxStaleSeconds > 0 &&
112
+ Date.now() - cache.fetchedAt < (ttlSeconds + maxStaleSeconds) * 1000) {
113
+ return cache.jwks;
114
+ }
115
+ throw err;
116
+ }
103
117
  finally {
104
118
  inflight = undefined;
105
119
  }
@@ -168,12 +182,20 @@ export function jwk(opts) {
168
182
  throw new Error("jwk(): fetchTtlSeconds must be a non-negative finite number.");
169
183
  }
170
184
  }
185
+ if (opts.maxStaleSeconds !== undefined) {
186
+ if (typeof opts.maxStaleSeconds !== "number" ||
187
+ !Number.isFinite(opts.maxStaleSeconds) ||
188
+ opts.maxStaleSeconds < 0) {
189
+ throw new Error("jwk(): maxStaleSeconds must be a non-negative finite number.");
190
+ }
191
+ }
171
192
  const realm = opts.realm ?? "api";
172
193
  if (/["\r\n\0]/.test(realm)) {
173
194
  throw new Error("jwk(): realm must not contain quotes, CR, LF, or NUL bytes.");
174
195
  }
175
196
  const ttl = opts.fetchTtlSeconds ?? 300;
176
- const loader = makeJwksLoader(opts.jwks, opts.fetch ?? globalThis.fetch, ttl);
197
+ const maxStale = opts.maxStaleSeconds ?? 3600;
198
+ const loader = makeJwksLoader(opts.jwks, opts.fetch ?? globalThis.fetch, ttl, maxStale);
177
199
  const algorithms = [...opts.algorithms];
178
200
  let cachedVerifier;
179
201
  let cachedJwksRef;
@@ -39,10 +39,15 @@ export interface LoadSheddingOptions {
39
39
  * @internal
40
40
  */
41
41
  export interface LoadSheddingSnapshot {
42
+ /** Sampled event-loop delay in milliseconds. */
42
43
  eventLoopDelayMs: number;
44
+ /** Sampled `heapUsed` in bytes. */
43
45
  heapUsedBytes: number;
46
+ /** Sampled resident set size (`rss`) in bytes. */
44
47
  rssBytes: number;
48
+ /** Sampled event-loop utilization in the range `[0, 1]`. */
45
49
  eventLoopUtilization: number;
50
+ /** Reason the request was shed, when a threshold is exceeded. */
46
51
  reason?: string;
47
52
  }
48
53
  /**
package/dist/logger.js CHANGED
@@ -72,7 +72,11 @@ const JWT_LIKE_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
72
72
  * conservatively to avoid false positives on ordinary identifiers.
73
73
  *
74
74
  * Sources (token formats published by each provider as of 2026):
75
- * - GitHub: `gh[opsur]_` 36–251 alphanumerics; `github_pat_` 40+ alnum/_
75
+ * - GitHub: `gh[opru]_` 36–251 alphanumerics (opaque); `ghs_` 36+ of
76
+ * alnum/`.`/`-`/`_` to also cover the 2026 stateless installation-token
77
+ * format (a ~520-char `ghs_`-prefixed JWT with two dots — see
78
+ * <https://github.blog/changelog/2026-05-15-github-app-installation-tokens-per-request-override-header/>);
79
+ * `github_pat_` 40+ alnum/_
76
80
  * - Slack: `xox[abprs]-` legacy/bot/user/refresh tokens
77
81
  * - AWS: `AKIA`/`ASIA` + 16 uppercase alphanumerics
78
82
  * - Stripe: `sk|rk|pk` + `_live_|_test_` + 20+ alphanumerics
@@ -82,7 +86,7 @@ const JWT_LIKE_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
82
86
  * - Anthropic: `sk-ant-` + 20+ alnum/_/-
83
87
  * - OpenAI: `sk-` + 20+ alnum/_/- (matched after the `sk-ant-` form)
84
88
  */
85
- const CREDENTIAL_LIKE_RE = /(?:gh[opsur]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{40,255}|xox[abprs]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20,}|AIza[A-Za-z0-9_-]{35}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,})/g;
89
+ const CREDENTIAL_LIKE_RE = /(?:ghs_[A-Za-z0-9._-]{36,1024}|gh[opru]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{40,255}|xox[abprs]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20,}|AIza[A-Za-z0-9_-]{35}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,})/g;
86
90
  function resolveRedaction(opt) {
87
91
  if (opt === false)
88
92
  return null;