@getstrata/core 2.1.0 → 2.2.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 (61) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/core/cache/createCacheStore.d.ts +3 -1
  3. package/dist/core/cache/redisCacheStore.d.ts +21 -8
  4. package/dist/core/cache/repository.d.ts +3 -2
  5. package/dist/core/cache/store.d.ts +4 -1
  6. package/dist/core/cache/taggedCache.d.ts +1 -1
  7. package/dist/core/contracts/di.d.ts +4 -2
  8. package/dist/core/contracts/serviceTokens.d.ts +6 -1
  9. package/dist/core/database/baseRepository.d.ts +0 -1
  10. package/dist/core/database/index.d.ts +1 -1
  11. package/dist/core/database/model.d.ts +51 -22
  12. package/dist/core/database/repositoryQuery.d.ts +16 -1
  13. package/dist/core/database/types.d.ts +25 -2
  14. package/dist/core/database/whereBuilder.d.ts +5 -5
  15. package/dist/core/events/deferredModelEvents.d.ts +2 -1
  16. package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
  17. package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
  18. package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
  19. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  20. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  21. package/dist/core/queue/index.d.ts +7 -0
  22. package/dist/core/queue/redisQueue.d.ts +1 -0
  23. package/dist/core/queue/resilientQueue.d.ts +2 -2
  24. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  25. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  26. package/dist/core/scheduler/leases.d.ts +40 -0
  27. package/dist/core/scheduler/schedule.d.ts +29 -5
  28. package/dist/core/tenant/tenantContext.d.ts +5 -4
  29. package/dist/core/tenant/tenantMiddleware.d.ts +7 -1
  30. package/dist/core/tracing/otel.d.ts +28 -26
  31. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  32. package/dist/entries/audit/exportAuditLogs.js +14 -2
  33. package/dist/entries/auth/basicAuthGuard.js +80 -0
  34. package/dist/entries/auth/jwtGuard.js +80 -0
  35. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  36. package/dist/entries/auth/sessionGuard.js +80 -0
  37. package/dist/entries/cache/createCacheStore.js +365 -119
  38. package/dist/entries/cache/repository.js +5 -0
  39. package/dist/entries/contracts/serviceTokens.js +83 -0
  40. package/dist/entries/database/factory.js +0 -3
  41. package/dist/entries/database/model.js +1 -2486
  42. package/dist/entries/database/repositoryQuery.js +67 -9
  43. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  44. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  45. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  46. package/dist/entries/http/throttleMiddleware.js +65 -16
  47. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  48. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  49. package/dist/entries/queue/createAppQueue.js +67 -6
  50. package/dist/entries/queue/publicQueue.js +67 -6
  51. package/dist/entries/queue/queueMetrics.js +67 -6
  52. package/dist/entries/queue/redisQueue.js +3 -0
  53. package/dist/entries/queue.js +30 -5
  54. package/dist/entries/scheduler/schedule.js +1 -72
  55. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  56. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  57. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  58. package/dist/framework/public-api.d.ts +13 -9
  59. package/dist/index.js +1745 -633
  60. package/dist/types/services.d.ts +3 -3
  61. package/package.json +17 -8
@@ -1,6 +1,182 @@
1
1
  // @bun
2
+ var __esm = (fn, res, err) => () => {
3
+ if (fn)
4
+ try {
5
+ res = fn(fn = 0);
6
+ } catch (e) {
7
+ err = [e];
8
+ }
9
+ if (err)
10
+ throw err[0];
11
+ return res;
12
+ };
13
+
14
+ // ../../src/core/runtime/asyncContextStore.ts
15
+ import { AsyncLocalStorage } from "async_hooks";
16
+ function createAsyncContextStore(key) {
17
+ const symbol = Symbol.for(key);
18
+ const globalRecord = globalThis;
19
+ const existing = globalRecord[symbol];
20
+ if (existing) {
21
+ return existing;
22
+ }
23
+ const store = new AsyncLocalStorage;
24
+ globalRecord[symbol] = store;
25
+ return store;
26
+ }
27
+ var init_asyncContextStore = () => {};
28
+
2
29
  // ../../src/core/http/scimThrottleMiddleware.ts
30
+ import { createHash as createHash2 } from "crypto";
31
+
32
+ // ../../src/core/runtime/boundedThrottleStore.ts
33
+ class BoundedThrottleStore {
34
+ decayMs;
35
+ now;
36
+ buckets = new Map;
37
+ maxBuckets;
38
+ pruneBatchSize;
39
+ disposed = false;
40
+ pruned = 0;
41
+ constructor(decayMs, options = {}, now = () => performance.now()) {
42
+ this.decayMs = decayMs;
43
+ this.now = now;
44
+ this.maxBuckets = options.maxBuckets ?? 1e4;
45
+ this.pruneBatchSize = options.pruneBatchSize ?? 32;
46
+ if (!Number.isFinite(decayMs) || decayMs < 0 || decayMs > 2147483647) {
47
+ throw new Error("Invalid memory throttle window.");
48
+ }
49
+ if (!Number.isSafeInteger(this.maxBuckets) || this.maxBuckets < 1 || this.maxBuckets > 1e6 || !Number.isSafeInteger(this.pruneBatchSize) || this.pruneBatchSize < 1 || this.pruneBatchSize > 1024) {
50
+ throw new Error("Invalid memory throttle storage bounds.");
51
+ }
52
+ }
53
+ consume(key) {
54
+ this.pruned = 0;
55
+ if (this.disposed)
56
+ return null;
57
+ const now = this.now();
58
+ const entries = this.buckets.entries();
59
+ for (let i = 0;i < this.pruneBatchSize; i++) {
60
+ const oldest = entries.next().value;
61
+ if (!oldest || oldest[1].resetAt > now)
62
+ break;
63
+ this.buckets.delete(oldest[0]);
64
+ this.pruned++;
65
+ }
66
+ const existing = this.buckets.get(key);
67
+ if (existing && existing.resetAt > now) {
68
+ existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + 1);
69
+ return existing.count;
70
+ }
71
+ this.buckets.delete(key);
72
+ if (this.buckets.size >= this.maxBuckets)
73
+ return null;
74
+ this.buckets.set(key, { count: 1, resetAt: now + this.decayMs });
75
+ return 1;
76
+ }
77
+ clear() {
78
+ this.buckets.clear();
79
+ }
80
+ dispose() {
81
+ this.disposed = true;
82
+ this.clear();
83
+ }
84
+ stats() {
85
+ return {
86
+ retainedBuckets: this.buckets.size,
87
+ prunedOnLastConsume: this.pruned,
88
+ disposed: this.disposed
89
+ };
90
+ }
91
+ }
92
+
93
+ // ../../src/core/security/timingSafeCompare.ts
94
+ import { timingSafeEqual } from "crypto";
95
+ function timingSafeCompareString(left, right) {
96
+ const leftBuffer = Buffer.from(left);
97
+ const rightBuffer = Buffer.from(right);
98
+ if (leftBuffer.length !== rightBuffer.length) {
99
+ return false;
100
+ }
101
+ return timingSafeEqual(leftBuffer, rightBuffer);
102
+ }
103
+
104
+ // ../../src/core/security/scimTenantTokens.ts
105
+ function parseScimTenantTokens(raw) {
106
+ const tokens = new Map;
107
+ if (!raw?.trim()) {
108
+ return tokens;
109
+ }
110
+ for (const entry of raw.split(",")) {
111
+ const [tenantPart, tokenPart] = entry.split(":");
112
+ if (!tenantPart || !tokenPart) {
113
+ continue;
114
+ }
115
+ const tenantId = Number.parseInt(tenantPart.trim(), 10);
116
+ const token = tokenPart.trim();
117
+ if (Number.isInteger(tenantId) && tenantId > 0 && token.length > 0) {
118
+ tokens.set(tenantId, token);
119
+ }
120
+ }
121
+ return tokens;
122
+ }
123
+ function resolveScimTenantFromToken(token) {
124
+ const presented = token.trim();
125
+ if (presented.length === 0) {
126
+ return null;
127
+ }
128
+ const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
129
+ for (const [tenantId, expectedToken] of tenantTokens) {
130
+ if (timingSafeCompareString(presented, expectedToken)) {
131
+ return tenantId;
132
+ }
133
+ }
134
+ const fallbackToken = process.env.SCIM_BEARER_TOKEN?.trim() ?? "";
135
+ if (fallbackToken.length === 0) {
136
+ return null;
137
+ }
138
+ if (timingSafeCompareString(presented, fallbackToken)) {
139
+ return 1;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ // ../../src/core/http/clientIp.ts
145
+ import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
146
+ function trustForwardedFor(env = process.env) {
147
+ return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
148
+ }
149
+ function isPrivateAddress(address) {
150
+ const ip = address.replace(/^::ffff:/i, "");
151
+ return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
152
+ }
153
+ function forwardedClientIp(request) {
154
+ const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
155
+ for (let index = hops.length - 1;index >= 0; index -= 1) {
156
+ const hop = hops[index];
157
+ if (hop && !isPrivateAddress(hop)) {
158
+ return hop;
159
+ }
160
+ }
161
+ if (hops.length > 0) {
162
+ return hops[hops.length - 1];
163
+ }
164
+ return request.headers.get("x-real-ip")?.trim() || undefined;
165
+ }
166
+ function readClientIp(request, env = process.env) {
167
+ if (trustForwardedFor(env)) {
168
+ const forwarded = forwardedClientIp(request);
169
+ if (forwarded) {
170
+ return forwarded;
171
+ }
172
+ }
173
+ return currentRequestMeta().ipAddress ?? undefined;
174
+ }
175
+
176
+ // ../../src/core/http/throttleMiddleware.ts
3
177
  import { createHash } from "crypto";
178
+ import { currentAuthUser } from "@getstrata/core/auth/authContext";
179
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
4
180
  var {RedisClient } = globalThis.Bun;
5
181
 
6
182
  // ../../src/core/runtime/appEnv.ts
@@ -89,83 +265,429 @@ function sdkClientClassName() {
89
265
  return fromName ? `${fromName}Client` : "AppClient";
90
266
  }
91
267
 
92
- // ../../src/core/http/clientIp.ts
93
- import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
94
- function trustForwardedFor(env = process.env) {
95
- return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
268
+ // ../../src/core/http/requestMetaContext.ts
269
+ init_asyncContextStore();
270
+ var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
271
+ function runWithRequestMeta(meta, callback) {
272
+ return requestMetaContext.run(meta, callback);
96
273
  }
97
- function isPrivateAddress(address) {
98
- const ip = address.replace(/^::ffff:/i, "");
99
- return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
274
+ function currentRequestMeta2() {
275
+ return requestMetaContext.getStore() ?? {
276
+ ipAddress: null,
277
+ userAgent: null
278
+ };
100
279
  }
101
- function forwardedClientIp(request) {
102
- const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
103
- for (let index = hops.length - 1;index >= 0; index -= 1) {
104
- const hop = hops[index];
105
- if (hop && !isPrivateAddress(hop)) {
106
- return hop;
280
+
281
+ // ../../src/core/runtime/frontendMode.ts
282
+ var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
283
+ var DEFAULT_SPA_PREFIX = "/app";
284
+ var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
285
+ function parseFrontendMode(value) {
286
+ const mode = (value ?? "api").trim();
287
+ if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
288
+ return mode;
289
+ }
290
+ return "api";
291
+ }
292
+ function readFrontendMode() {
293
+ return parseFrontendMode(process.env.FRONTEND_MODE);
294
+ }
295
+ function isViewsMode(mode) {
296
+ return mode === "server-htmx" || mode === "hybrid";
297
+ }
298
+ function isSpaMode(mode) {
299
+ return mode === "spa-react" || mode === "hybrid";
300
+ }
301
+ function isViewsEnabled() {
302
+ return isViewsMode(readFrontendMode());
303
+ }
304
+ function isSpaEnabled() {
305
+ return isSpaMode(readFrontendMode());
306
+ }
307
+ function normalizeSpaPrefix(value) {
308
+ const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
309
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
310
+ const trimmed = withSlash.replace(/\/+$/, "");
311
+ if (trimmed.length === 0 || trimmed === "/") {
312
+ return DEFAULT_SPA_PREFIX;
313
+ }
314
+ return trimmed;
315
+ }
316
+ function readSpaPrefix() {
317
+ return normalizeSpaPrefix(process.env.SPA_PREFIX);
318
+ }
319
+
320
+ // ../../src/core/view/webErrorView.ts
321
+ import { currentRequestMeta as currentRequestMeta3 } from "@getstrata/core/http/requestMetaContext";
322
+
323
+ // ../../src/core/http/safeInternalPath.ts
324
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
325
+ function looksLikeExternalTarget(value) {
326
+ const trimmed = value.trim();
327
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
328
+ return true;
329
+ }
330
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
331
+ return true;
332
+ }
333
+ try {
334
+ const decoded = decodeURIComponent(trimmed);
335
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
336
+ return true;
107
337
  }
338
+ } catch {
339
+ return true;
108
340
  }
109
- if (hops.length > 0) {
110
- return hops[hops.length - 1];
341
+ return false;
342
+ }
343
+ function sanitizeNestedQuery(search) {
344
+ if (!search) {
345
+ return "";
111
346
  }
112
- return request.headers.get("x-real-ip")?.trim() || undefined;
347
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
348
+ const next = new URLSearchParams;
349
+ for (const [key, value] of params.entries()) {
350
+ if (NESTED_REDIRECT_KEYS.has(key)) {
351
+ const sanitized = sanitizeInternalPath(value, "/");
352
+ next.set(key, sanitized);
353
+ continue;
354
+ }
355
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
356
+ continue;
357
+ }
358
+ next.set(key, value);
359
+ }
360
+ const serialized = next.toString();
361
+ return serialized ? `?${serialized}` : "";
113
362
  }
114
- function readClientIp(request, env = process.env) {
115
- if (trustForwardedFor(env)) {
116
- const forwarded = forwardedClientIp(request);
117
- if (forwarded) {
118
- return forwarded;
363
+ function sanitizeInternalPath(raw, fallback = "/") {
364
+ const trimmed = raw.trim();
365
+ if (looksLikeExternalTarget(trimmed)) {
366
+ return fallback;
367
+ }
368
+ const queryIndex = trimmed.indexOf("?");
369
+ const hashIndex = trimmed.indexOf("#");
370
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
371
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
372
+ if (looksLikeExternalTarget(pathname)) {
373
+ return fallback;
374
+ }
375
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
376
+ return `${pathname}${sanitizeNestedQuery(search)}`;
377
+ }
378
+ function safeInternalRedirectPath(request, fallback = "/") {
379
+ const url = new URL(request.url);
380
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
381
+ }
382
+ function loginRedirectLocation(request) {
383
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
384
+ }
385
+
386
+ // ../../src/core/view/htmlResponse.ts
387
+ function htmlResponse(html, init = {}) {
388
+ return new Response(html, {
389
+ status: init.status ?? 200,
390
+ statusText: init.statusText,
391
+ headers: {
392
+ "Content-Type": "text/html; charset=utf-8"
119
393
  }
394
+ });
395
+ }
396
+
397
+ // ../../src/core/view/webErrorView.ts
398
+ var configuredErrorView = {};
399
+ function escapeHtml(value) {
400
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
401
+ }
402
+ function renderKernelErrorChrome(input) {
403
+ const title = escapeHtml(input.title);
404
+ const message = escapeHtml(input.message);
405
+ const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
406
+ const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
407
+ const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
408
+ return `<!doctype html>
409
+ <html lang="en">
410
+ <head>
411
+ <meta charset="UTF-8" />
412
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
413
+ <title>${title}</title>
414
+ <link rel="stylesheet" href="/assets/app.css" />
415
+ </head>
416
+ <body>
417
+ <header class="site-header">
418
+ <a class="brand" href="/">Home</a>
419
+ </header>
420
+ <main class="site-main">
421
+ <section class="page-header">
422
+ <h1>${title}</h1>
423
+ <p>${message}</p>
424
+ ${details}
425
+ ${goBack}
426
+ </section>
427
+ </main>
428
+ </body>
429
+ </html>
430
+ `;
431
+ }
432
+ async function renderWebErrorHtml(input) {
433
+ const render = configuredErrorView.render;
434
+ if (!render) {
435
+ return renderKernelErrorChrome(input);
120
436
  }
121
- return currentRequestMeta().ipAddress ?? undefined;
437
+ try {
438
+ return await render({
439
+ ...input,
440
+ request: input.request ?? currentRequestMeta3().request
441
+ });
442
+ } catch {
443
+ return renderKernelErrorChrome(input);
444
+ }
445
+ }
446
+ async function htmlErrorResponse(input) {
447
+ return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
448
+ }
449
+
450
+ // ../../src/core/http/contentNegotiation.ts
451
+ function requestPrefersJson(request) {
452
+ if (!request) {
453
+ return true;
454
+ }
455
+ if (request.headers.get("HX-Request") === "true") {
456
+ return false;
457
+ }
458
+ const pathname = new URL(request.url).pathname;
459
+ if (pathname.startsWith("/api/")) {
460
+ return true;
461
+ }
462
+ const accept = request.headers.get("accept")?.toLowerCase() ?? "";
463
+ if (accept.includes("text/html")) {
464
+ return false;
465
+ }
466
+ if (accept.includes("application/json")) {
467
+ return true;
468
+ }
469
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
470
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
471
+ return false;
472
+ }
473
+ return false;
474
+ }
475
+
476
+ // ../../src/core/http/throttleResponse.ts
477
+ async function tooManyRequestsResponse(request, message, decaySeconds) {
478
+ const retryAfter = { "retry-after": String(decaySeconds) };
479
+ if (requestPrefersJson(request) || !isViewsEnabled()) {
480
+ return Response.json({ error: message }, {
481
+ status: 429,
482
+ headers: retryAfter
483
+ });
484
+ }
485
+ const html = await htmlErrorResponse({
486
+ status: 429,
487
+ title: "Too Many Requests",
488
+ message,
489
+ request
490
+ });
491
+ const headers = new Headers(html.headers);
492
+ headers.set("retry-after", String(decaySeconds));
493
+ return new Response(html.body, { status: 429, headers });
494
+ }
495
+
496
+ // ../../src/core/http/throttleMiddleware.ts
497
+ function resolveThrottleIdentity(request) {
498
+ const user = currentAuthUser();
499
+ if (user?.tokenId !== undefined) {
500
+ return `token:${user.tokenId}`;
501
+ }
502
+ if (user) {
503
+ return `user:${user.id}`;
504
+ }
505
+ return readClientIp(request) ?? "unknown";
506
+ }
507
+ var CONSUME_THROTTLE = `
508
+ local count = redis.call('INCR', KEYS[1])
509
+ local ttl = redis.call('PTTL', KEYS[1])
510
+ if ttl < 0 then
511
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
512
+ end
513
+ return count
514
+ `;
515
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
516
+ const bucket = JSON.stringify([
517
+ tenantId,
518
+ request.method,
519
+ currentRequestMeta2().routeTemplate ?? "__unmatched__",
520
+ identity
521
+ ]);
522
+ return namespacedRedisKey(`${prefix}:${createHash("sha256").update(bucket).digest("hex")}`);
523
+ }
524
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
525
+ const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
526
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
527
+ throw new Error("Invalid throttle expiry or command timeout.");
528
+ }
529
+ if (signal?.aborted)
530
+ throw new Error("Throttle consumer is disposed.");
531
+ let onAbort;
532
+ let timer;
533
+ try {
534
+ const cancelled = new Promise((_resolve, reject) => {
535
+ onAbort = () => reject(new Error("Throttle consumer is disposed."));
536
+ signal?.addEventListener("abort", onAbort, { once: true });
537
+ });
538
+ const result = await Promise.race([
539
+ cancelled,
540
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
541
+ new Promise((_resolve, reject) => {
542
+ timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
543
+ })
544
+ ]);
545
+ const count = Number(result);
546
+ if (!Number.isSafeInteger(count) || count < 1)
547
+ throw new Error("Invalid throttle store response.");
548
+ return count;
549
+ } finally {
550
+ clearTimeout(timer);
551
+ if (onAbort)
552
+ signal?.removeEventListener("abort", onAbort);
553
+ }
554
+ }
555
+ function createRedisThrottleConsumer(options) {
556
+ let ownedClient;
557
+ const controller = new AbortController;
558
+ const consume = async (key, decaySeconds) => {
559
+ if (controller.signal.aborted)
560
+ throw new Error("Throttle consumer is disposed.");
561
+ if (!options.redisClient && !ownedClient) {
562
+ ownedClient = new RedisClient(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
563
+ }
564
+ const client = options.redisClient ?? ownedClient;
565
+ if (!client)
566
+ throw new Error("Throttle client is required.");
567
+ try {
568
+ const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
569
+ if (controller.signal.aborted)
570
+ throw new Error("Throttle consumer is disposed.");
571
+ return attempts;
572
+ } catch (error) {
573
+ if (client === ownedClient) {
574
+ ownedClient.close();
575
+ ownedClient = undefined;
576
+ }
577
+ throw error;
578
+ }
579
+ };
580
+ return Object.assign(consume, {
581
+ isDisposed: () => controller.signal.aborted,
582
+ dispose() {
583
+ if (controller.signal.aborted)
584
+ return;
585
+ controller.abort();
586
+ const client = ownedClient;
587
+ ownedClient = undefined;
588
+ client?.close();
589
+ }
590
+ });
591
+ }
592
+ function throttleUnavailableResponse() {
593
+ return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
594
+ status: 503,
595
+ headers: { "retry-after": "1" }
596
+ });
597
+ }
598
+ function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
599
+ const limit = options.quotaPolicy ? options.quotaPolicy({
600
+ request,
601
+ tenant: currentTenant(),
602
+ identity,
603
+ routeTemplate: currentRequestMeta2().routeTemplate ?? "__unmatched__",
604
+ maxAttempts: options.maxAttempts
605
+ }) : options.maxAttempts;
606
+ if (!Number.isSafeInteger(limit) || limit < 0) {
607
+ throw new Error("Invalid application quota.");
608
+ }
609
+ return limit;
610
+ }
611
+ function createThrottleMiddleware(options) {
612
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
613
+ throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
614
+ }
615
+ const consume = createRedisThrottleConsumer(options);
616
+ const prefix = options.keyPrefix ?? "throttle";
617
+ const middleware = async (request, next) => {
618
+ const identity = resolveThrottleIdentity(request);
619
+ let maxAttempts;
620
+ try {
621
+ maxAttempts = resolveThrottleQuota(request, options, identity);
622
+ } catch {
623
+ return throttleUnavailableResponse();
624
+ }
625
+ const key = redisThrottleKey(request, prefix, identity);
626
+ let attempts;
627
+ try {
628
+ attempts = await consume(key, options.decaySeconds);
629
+ } catch {
630
+ return throttleUnavailableResponse();
631
+ }
632
+ if (consume.isDisposed())
633
+ return throttleUnavailableResponse();
634
+ if (attempts > maxAttempts) {
635
+ return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
636
+ }
637
+ return await next();
638
+ };
639
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
122
640
  }
123
641
 
124
642
  // ../../src/core/http/scimThrottleMiddleware.ts
125
- var memoryBuckets = new Map;
126
643
  function resolveScimIdentity(request) {
127
644
  const authorization = request.headers.get("authorization") ?? "";
128
645
  const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
129
646
  if (presented.length > 0) {
130
- return createHash("sha256").update(presented).digest("hex");
647
+ return createHash2("sha256").update(presented).digest("hex");
131
648
  }
132
649
  return readClientIp(request) ?? "unknown";
133
650
  }
134
- function throttleFromMemory(key, maxAttempts, decaySeconds) {
135
- const now = Date.now();
136
- const existing = memoryBuckets.get(key);
137
- if (!existing || existing.resetAt <= now) {
138
- memoryBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
139
- return null;
140
- }
141
- existing.count += 1;
142
- if (existing.count > maxAttempts) {
143
- return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(decaySeconds) } });
144
- }
145
- return null;
146
- }
147
651
  function createScimThrottleMiddleware(options) {
148
- const client = options.redisUrl ? new RedisClient(options.redisUrl) : null;
149
- return async (request, next) => {
652
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
653
+ throw new Error("Invalid SCIM throttle limit.");
654
+ const redisUrl = options.redisUrl?.trim() ?? "";
655
+ const distributed = Boolean(redisUrl || options.redisClient);
656
+ const store = distributed ? null : new BoundedThrottleStore(options.decaySeconds * 1000, options);
657
+ const consume = distributed ? createRedisThrottleConsumer({ ...options, redisUrl }) : null;
658
+ let disposed = false;
659
+ const middleware = async (request, next) => {
660
+ if (disposed)
661
+ return throttleUnavailableResponse();
150
662
  const identity = resolveScimIdentity(request);
151
- const key = `${namespacedRedisKey("scim-throttle:")}${identity}`;
152
- if (client) {
153
- const attempts = Number(await client.incr(key));
154
- if (attempts === 1) {
155
- await client.expire(key, options.decaySeconds);
156
- }
157
- if (attempts > options.maxAttempts) {
158
- return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
159
- }
160
- return await next();
161
- }
162
- const limited = throttleFromMemory(key, options.maxAttempts, options.decaySeconds);
163
- if (limited) {
164
- return limited;
663
+ const token = request.headers.get("authorization")?.startsWith("Bearer ") ? request.headers.get("authorization")?.slice("Bearer ".length).trim() ?? "" : "";
664
+ const key = redisThrottleKey(request, options.keyPrefix ?? "scim-throttle:v2", identity, resolveScimTenantFromToken(token) ?? undefined);
665
+ let attempts;
666
+ try {
667
+ attempts = consume ? await consume(key, options.decaySeconds) : store?.consume(key);
668
+ } catch {
669
+ return throttleUnavailableResponse();
165
670
  }
671
+ if (disposed)
672
+ return throttleUnavailableResponse();
673
+ if (attempts === null || attempts === undefined)
674
+ return throttleUnavailableResponse();
675
+ if (attempts > options.maxAttempts)
676
+ return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
166
677
  return await next();
167
678
  };
679
+ return Object.assign(middleware, {
680
+ dispose: () => {
681
+ if (disposed)
682
+ return;
683
+ disposed = true;
684
+ store?.dispose();
685
+ consume?.dispose();
686
+ },
687
+ stats: () => store?.stats()
688
+ });
168
689
  }
169
690
  export {
170
- createScimThrottleMiddleware
691
+ createScimThrottleMiddleware,
692
+ resolveScimIdentity
171
693
  };