@getstrata/core 1.1.8 → 2.0.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 (49) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/core/database/transaction.d.ts +2 -1
  3. package/dist/core/database/transactionControl.d.ts +5 -0
  4. package/dist/core/events/deferredModelEvents.d.ts +3 -0
  5. package/dist/core/events/index.d.ts +1 -0
  6. package/dist/core/http/loginThrottleMiddleware.d.ts +3 -0
  7. package/dist/core/http/metricsMiddleware.d.ts +1 -2
  8. package/dist/core/http/requestMetaContext.d.ts +2 -0
  9. package/dist/core/http/throttleMiddleware.d.ts +11 -2
  10. package/dist/core/metrics/prometheus.d.ts +11 -1
  11. package/dist/core/queue/jobRunner.d.ts +3 -1
  12. package/dist/core/queue/redisQueue.d.ts +20 -3
  13. package/dist/core/tenant/databaseTenantContext.d.ts +5 -1
  14. package/dist/entries/audit/exportAuditLogs.js +751 -97
  15. package/dist/entries/auth/accessControl.js +16 -2
  16. package/dist/entries/auth/basicAuthGuard.js +271 -224
  17. package/dist/entries/auth/membershipMiddleware.js +17 -0
  18. package/dist/entries/auth/membershipScope.js +21 -6
  19. package/dist/entries/auth/membershipService.js +20 -5
  20. package/dist/entries/auth/oauth/samlProvider.js +187 -142
  21. package/dist/entries/auth/oneTimeToken.js +142 -123
  22. package/dist/entries/auth/passwordLogin.js +212 -191
  23. package/dist/entries/auth/policy.js +16 -2
  24. package/dist/entries/auth/saml/samlServiceProvider.js +187 -142
  25. package/dist/entries/auth/scimAuthMiddleware.js +732 -64
  26. package/dist/entries/database/migrations.js +144 -125
  27. package/dist/entries/database/schema.js +58 -39
  28. package/dist/entries/http/csrfMiddleware.js +28 -14
  29. package/dist/entries/http/csrfToken.js +28 -14
  30. package/dist/entries/http/flashMiddleware.js +28 -14
  31. package/dist/entries/http/metricsMiddleware.js +124 -27
  32. package/dist/entries/http/response.js +107 -0
  33. package/dist/entries/http/routeMiddleware.js +40 -0
  34. package/dist/entries/http/throttleMiddleware.js +124 -11
  35. package/dist/entries/jobs/exportAuditLogsJob.js +751 -97
  36. package/dist/entries/metrics/prometheus.js +71 -11
  37. package/dist/entries/queue/createAppQueue.js +215 -21
  38. package/dist/entries/queue/jobRunner.js +3 -2
  39. package/dist/entries/queue/publicQueue.js +215 -21
  40. package/dist/entries/queue/queueMetrics.js +215 -21
  41. package/dist/entries/queue/redisQueue.js +223 -22
  42. package/dist/entries/runtime/asyncContextStore.js +16 -0
  43. package/dist/entries/tenant/databaseTenantContext.js +666 -11
  44. package/dist/entries/tenant/tenancyConfig.js +19 -1
  45. package/dist/entries/tenant/tenantDatabaseScope.js +688 -18
  46. package/dist/entries/tracing/tracingMiddleware.js +28 -14
  47. package/dist/framework/public-api.d.ts +3 -3
  48. package/dist/index.js +1344 -651
  49. package/package.json +1 -1
@@ -1,6 +1,50 @@
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/metrics/prometheus.ts
30
+ var HTTP_DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 1e4];
31
+ function emptyHistogram() {
32
+ return {
33
+ cumulative: HTTP_DURATION_BUCKETS_MS.map(() => 0),
34
+ sum: 0,
35
+ count: 0
36
+ };
37
+ }
38
+
3
39
  class PrometheusRegistry {
40
+ maxSeries;
41
+ seriesLabels = new Map;
42
+ constructor(maxSeries = 4096) {
43
+ this.maxSeries = maxSeries;
44
+ if (!Number.isSafeInteger(maxSeries) || maxSeries < 1) {
45
+ throw new Error("Metrics maxSeries must be a positive safe integer.");
46
+ }
47
+ }
4
48
  httpRequestsTotal = new Map;
5
49
  httpRequestDurationMs = new Map;
6
50
  incrementHttpRequest(labels) {
@@ -8,10 +52,20 @@ class PrometheusRegistry {
8
52
  this.httpRequestsTotal.set(key, (this.httpRequestsTotal.get(key) ?? 0) + 1);
9
53
  }
10
54
  observeHttpDuration(labels, durationMs) {
55
+ if (!Number.isFinite(durationMs) || durationMs < 0)
56
+ return;
11
57
  const key = this.metricKey(labels);
12
- const samples = this.httpRequestDurationMs.get(key) ?? [];
13
- samples.push(durationMs);
14
- this.httpRequestDurationMs.set(key, samples);
58
+ const histogram = this.httpRequestDurationMs.get(key) ?? emptyHistogram();
59
+ histogram.count += 1;
60
+ histogram.sum += durationMs;
61
+ for (let index = 0;index < HTTP_DURATION_BUCKETS_MS.length; index += 1) {
62
+ const bound = HTTP_DURATION_BUCKETS_MS[index];
63
+ if (bound !== undefined && durationMs <= bound) {
64
+ const count = histogram.cumulative[index] ?? 0;
65
+ histogram.cumulative[index] = count + 1;
66
+ }
67
+ }
68
+ this.httpRequestDurationMs.set(key, histogram);
15
69
  }
16
70
  renderMetrics() {
17
71
  const lines = [
@@ -21,16 +75,22 @@ class PrometheusRegistry {
21
75
  for (const [key, value] of this.httpRequestsTotal) {
22
76
  lines.push(`http_requests_total{${key}} ${value}`);
23
77
  }
24
- lines.push("# HELP http_request_duration_ms_sum Sum of HTTP request durations in milliseconds.", "# TYPE http_request_duration_ms_sum counter");
25
- for (const [key, samples] of this.httpRequestDurationMs) {
26
- const sum = samples.reduce((total, sample) => total + sample, 0);
27
- lines.push(`http_request_duration_ms_sum{${key}} ${sum}`);
78
+ lines.push("# HELP http_request_duration_ms HTTP request duration in milliseconds.", "# TYPE http_request_duration_ms histogram");
79
+ for (const [key, histogram] of this.httpRequestDurationMs) {
80
+ for (let index = 0;index < HTTP_DURATION_BUCKETS_MS.length; index += 1) {
81
+ const bound = HTTP_DURATION_BUCKETS_MS[index];
82
+ lines.push(`http_request_duration_ms_bucket{${key},le="${bound}"} ${histogram.cumulative[index] ?? 0}`);
83
+ }
84
+ lines.push(`http_request_duration_ms_bucket{${key},le="+Inf"} ${histogram.count}`);
85
+ lines.push(`http_request_duration_ms_sum{${key}} ${histogram.sum}`);
86
+ lines.push(`http_request_duration_ms_count{${key}} ${histogram.count}`);
28
87
  }
29
88
  return `${lines.join(`
30
89
  `)}
31
90
  `;
32
91
  }
33
92
  resetForTests() {
93
+ this.seriesLabels.clear();
34
94
  this.httpRequestsTotal.clear();
35
95
  this.httpRequestDurationMs.clear();
36
96
  }
@@ -40,9 +100,10 @@ class PrometheusRegistry {
40
100
  let totalRequests = 0;
41
101
  for (const [key, count] of this.httpRequestsTotal) {
42
102
  totalRequests += count;
43
- const method = key.match(/method="([^"]+)"/)?.[1] ?? "GET";
44
- const path = key.match(/path="([^"]+)"/)?.[1] ?? "/";
45
- const status = key.match(/status="([^"]+)"/)?.[1] ?? "200";
103
+ const labels = this.seriesLabels.get(key);
104
+ if (!labels)
105
+ continue;
106
+ const { method, path, status } = labels;
46
107
  byStatus[status] = (byStatus[status] ?? 0) + count;
47
108
  const pathKey = `${method} ${path}`;
48
109
  const existing = pathCounts.get(pathKey);
@@ -59,33 +120,69 @@ class PrometheusRegistry {
59
120
  topPaths
60
121
  };
61
122
  }
123
+ getStorageStats() {
124
+ return {
125
+ series: this.seriesLabels.size,
126
+ histograms: this.httpRequestDurationMs.size,
127
+ bucketCounters: this.httpRequestDurationMs.size * HTTP_DURATION_BUCKETS_MS.length
128
+ };
129
+ }
62
130
  metricKey(labels) {
63
- return `method="${labels.method}",path="${labels.path}",status="${labels.status}"`;
131
+ const normalized = {
132
+ method: /^(GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|OPTIONS|TRACE)$/.test(labels.method) ? labels.method : "OTHER",
133
+ path: labels.path.length <= 512 ? labels.path : "__overflow__",
134
+ status: /^[1-5][0-9]{2}$/.test(labels.status) ? labels.status : "000"
135
+ };
136
+ const escapeLabel = (value) => value.replaceAll("\\", "\\\\").replaceAll(`
137
+ `, "\\n").replaceAll('"', "\\\"");
138
+ const encode = (value) => `method="${value.method}",path="${escapeLabel(value.path)}",status="${value.status}"`;
139
+ let key = encode(normalized);
140
+ if (!this.seriesLabels.has(key)) {
141
+ if (this.seriesLabels.size >= this.maxSeries) {
142
+ const overflow = { method: "OTHER", path: "__overflow__", status: "000" };
143
+ key = encode(overflow);
144
+ this.seriesLabels.set(key, overflow);
145
+ } else {
146
+ this.seriesLabels.set(key, normalized);
147
+ }
148
+ }
149
+ return key;
64
150
  }
65
151
  }
66
152
  var prometheusRegistry = new PrometheusRegistry;
67
153
 
68
- // ../../src/core/http/metricsMiddleware.ts
69
- function normalizeMetricPath(pathname) {
70
- return pathname.replace(/\/\d+/g, "/:id").replace(/\/[0-9a-f-]{36}/gi, "/:id");
154
+ // ../../src/core/http/requestMetaContext.ts
155
+ init_asyncContextStore();
156
+ var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
157
+ function runWithRequestMeta(meta, callback) {
158
+ return requestMetaContext.run(meta, callback);
159
+ }
160
+ function currentRequestMeta() {
161
+ return requestMetaContext.getStore() ?? {
162
+ ipAddress: null,
163
+ userAgent: null
164
+ };
71
165
  }
166
+
167
+ // ../../src/core/http/metricsMiddleware.ts
72
168
  function createMetricsMiddleware() {
73
169
  return async (request, next) => {
74
170
  const startedAt = performance.now();
75
- const response = await next();
76
- const durationMs = performance.now() - startedAt;
77
- const path = normalizeMetricPath(new URL(request.url).pathname);
78
- const labels = {
79
- method: request.method,
80
- path,
81
- status: String(response.status)
82
- };
83
- prometheusRegistry.incrementHttpRequest(labels);
84
- prometheusRegistry.observeHttpDuration(labels, durationMs);
85
- return response;
171
+ let response;
172
+ try {
173
+ response = await next();
174
+ return response;
175
+ } finally {
176
+ const labels = {
177
+ method: request.method,
178
+ path: currentRequestMeta().routeTemplate ?? "__unmatched__",
179
+ status: String(response?.status ?? 500)
180
+ };
181
+ prometheusRegistry.incrementHttpRequest(labels);
182
+ prometheusRegistry.observeHttpDuration(labels, performance.now() - startedAt);
183
+ }
86
184
  };
87
185
  }
88
186
  export {
89
- createMetricsMiddleware,
90
- normalizeMetricPath
187
+ createMetricsMiddleware
91
188
  };
@@ -1,4 +1,105 @@
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
+
29
+ // ../../src/core/database/transactionControl.ts
30
+ function unwrapTransactionRollback(error, depth = 0) {
31
+ if (error instanceof TransactionRollback) {
32
+ return error;
33
+ }
34
+ if (depth >= 5 || !(error instanceof Error) || error.cause === undefined) {
35
+ return null;
36
+ }
37
+ return unwrapTransactionRollback(error.cause, depth + 1);
38
+ }
39
+ function requestTransactionRollback() {
40
+ const state = transactionScopes.getStore();
41
+ const index = state ? state.rollback.length - 1 : -1;
42
+ if (!state || index < 0) {
43
+ return;
44
+ }
45
+ const flags = state.rollback;
46
+ const current = flags[index];
47
+ if (current === undefined) {
48
+ return;
49
+ }
50
+ flags[index] = true;
51
+ }
52
+ function scopeRequestsRollback() {
53
+ const state = transactionScopes.getStore();
54
+ if (!state || state.rollback.length === 0) {
55
+ return false;
56
+ }
57
+ return state.rollback[state.rollback.length - 1] === true;
58
+ }
59
+ async function commitOrRollbackScope(result) {
60
+ if (scopeRequestsRollback()) {
61
+ throw new TransactionRollback(result);
62
+ }
63
+ return result;
64
+ }
65
+ async function settleTransaction(work) {
66
+ try {
67
+ return await work();
68
+ } catch (error) {
69
+ const rollback = unwrapTransactionRollback(error);
70
+ if (rollback) {
71
+ return rollback.result;
72
+ }
73
+ throw error;
74
+ }
75
+ }
76
+ async function runWithTransactionScope(callback) {
77
+ const existing = transactionScopes.getStore();
78
+ if (existing) {
79
+ existing.rollback.push(false);
80
+ try {
81
+ return await callback();
82
+ } finally {
83
+ existing.rollback.pop();
84
+ }
85
+ }
86
+ const state = { rollback: [false] };
87
+ return await transactionScopes.run(state, callback);
88
+ }
89
+ var TransactionRollback, transactionScopes;
90
+ var init_transactionControl = __esm(() => {
91
+ init_asyncContextStore();
92
+ TransactionRollback = class TransactionRollback extends Error {
93
+ result;
94
+ constructor(result) {
95
+ super("Transaction rolled back");
96
+ this.name = "TransactionRollback";
97
+ this.result = result;
98
+ }
99
+ };
100
+ transactionScopes = createAsyncContextStore("@getstrata/transactionScopes");
101
+ });
102
+
2
103
  // ../../src/core/http/response.ts
3
104
  import { toHttpError as toHttpError3 } from "@getstrata/core/errors/http";
4
105
 
@@ -121,6 +222,9 @@ function isUniqueConstraintError(error) {
121
222
  return getPostgresSqlState(error) === "23505";
122
223
  }
123
224
 
225
+ // ../../src/core/http/response.ts
226
+ init_transactionControl();
227
+
124
228
  // ../../src/core/http/webErrorResponse.ts
125
229
  import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
126
230
  import { appLogger } from "@getstrata/core/logging/logger";
@@ -430,6 +534,7 @@ function withErrorHandling(handler) {
430
534
  try {
431
535
  return await handler(...args);
432
536
  } catch (error) {
537
+ requestTransactionRollback();
433
538
  const request = args.find((arg) => arg instanceof Request);
434
539
  const webResponse = await webErrorResponse(error, request);
435
540
  if (webResponse) {
@@ -444,6 +549,7 @@ function withJsonErrorHandling(handler) {
444
549
  try {
445
550
  return await handler(...args);
446
551
  } catch (error) {
552
+ requestTransactionRollback();
447
553
  return errorResponse(error);
448
554
  }
449
555
  };
@@ -453,6 +559,7 @@ function createJsonErrorMiddleware() {
453
559
  try {
454
560
  return await next();
455
561
  } catch (error) {
562
+ requestTransactionRollback();
456
563
  return errorResponse(error);
457
564
  }
458
565
  };
@@ -1,4 +1,44 @@
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
+
29
+ // ../../src/core/http/requestMetaContext.ts
30
+ init_asyncContextStore();
31
+ var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
32
+ function runWithRequestMeta(meta, callback) {
33
+ return requestMetaContext.run(meta, callback);
34
+ }
35
+ function currentRequestMeta() {
36
+ return requestMetaContext.getStore() ?? {
37
+ ipAddress: null,
38
+ userAgent: null
39
+ };
40
+ }
41
+
2
42
  // ../../src/core/http/middleware.ts
3
43
  function composeMiddleware(...middleware) {
4
44
  return (handler) => {
@@ -1,5 +1,33 @@
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/throttleMiddleware.ts
30
+ import { createHash } from "crypto";
3
31
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
4
32
  import { currentTenant, rateLimitMultiplierForPlan } from "@getstrata/core/tenant/tenantContext";
5
33
  var {RedisClient } = globalThis.Bun;
@@ -122,6 +150,19 @@ function readClientIp(request, env = process.env) {
122
150
  return currentRequestMeta().ipAddress ?? undefined;
123
151
  }
124
152
 
153
+ // ../../src/core/http/requestMetaContext.ts
154
+ init_asyncContextStore();
155
+ var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
156
+ function runWithRequestMeta(meta, callback) {
157
+ return requestMetaContext.run(meta, callback);
158
+ }
159
+ function currentRequestMeta2() {
160
+ return requestMetaContext.getStore() ?? {
161
+ ipAddress: null,
162
+ userAgent: null
163
+ };
164
+ }
165
+
125
166
  // ../../src/core/runtime/frontendMode.ts
126
167
  var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
127
168
  var DEFAULT_SPA_PREFIX = "/app";
@@ -162,7 +203,7 @@ function readSpaPrefix() {
162
203
  }
163
204
 
164
205
  // ../../src/core/view/webErrorView.ts
165
- import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
206
+ import { currentRequestMeta as currentRequestMeta3 } from "@getstrata/core/http/requestMetaContext";
166
207
 
167
208
  // ../../src/core/http/safeInternalPath.ts
168
209
  var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
@@ -281,7 +322,7 @@ async function renderWebErrorHtml(input) {
281
322
  try {
282
323
  return await render({
283
324
  ...input,
284
- request: input.request ?? currentRequestMeta2().request
325
+ request: input.request ?? currentRequestMeta3().request
285
326
  });
286
327
  } catch {
287
328
  return renderKernelErrorChrome(input);
@@ -348,16 +389,84 @@ function resolveThrottleIdentity(request) {
348
389
  }
349
390
  return readClientIp(request) ?? "unknown";
350
391
  }
392
+ var CONSUME_THROTTLE = `
393
+ local count = redis.call('INCR', KEYS[1])
394
+ local ttl = redis.call('PTTL', KEYS[1])
395
+ if ttl < 0 then
396
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
397
+ end
398
+ return count
399
+ `;
400
+ function redisThrottleKey(request, prefix, identity) {
401
+ const bucket = JSON.stringify([
402
+ currentTenant()?.id ?? null,
403
+ request.method,
404
+ currentRequestMeta2().routeTemplate ?? "__unmatched__",
405
+ identity
406
+ ]);
407
+ return namespacedRedisKey(`${prefix}:${createHash("sha256").update(bucket).digest("hex")}`);
408
+ }
409
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
410
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
411
+ throw new Error("Invalid throttle expiry or command timeout.");
412
+ }
413
+ let timer;
414
+ try {
415
+ const result = await Promise.race([
416
+ client.send("EVAL", [
417
+ CONSUME_THROTTLE,
418
+ "1",
419
+ key,
420
+ String(Math.max(1, Math.ceil(decaySeconds * 1000)))
421
+ ]),
422
+ new Promise((_resolve, reject) => {
423
+ timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
424
+ })
425
+ ]);
426
+ const count = Number(result);
427
+ if (!Number.isSafeInteger(count) || count < 1)
428
+ throw new Error("Invalid throttle store response.");
429
+ return count;
430
+ } finally {
431
+ clearTimeout(timer);
432
+ }
433
+ }
434
+ function createRedisThrottleConsumer(options) {
435
+ let ownedClient;
436
+ return async (key, decaySeconds) => {
437
+ if (!options.redisClient && !ownedClient) {
438
+ ownedClient = new RedisClient(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
439
+ }
440
+ const client = options.redisClient ?? ownedClient;
441
+ if (!client)
442
+ throw new Error("Throttle client is required.");
443
+ try {
444
+ return await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs);
445
+ } catch (error) {
446
+ if (client === ownedClient) {
447
+ ownedClient.close();
448
+ ownedClient = undefined;
449
+ }
450
+ throw error;
451
+ }
452
+ };
453
+ }
454
+ function throttleUnavailableResponse() {
455
+ return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
456
+ status: 503,
457
+ headers: { "retry-after": "1" }
458
+ });
459
+ }
351
460
  function createThrottleMiddleware(options) {
352
- const client = new RedisClient(options.redisUrl);
353
- const prefix = options.keyPrefix ?? namespacedRedisKey("throttle:");
461
+ const consume = createRedisThrottleConsumer(options);
462
+ const prefix = options.keyPrefix ?? "throttle";
354
463
  return async (request, next) => {
355
- const identity = resolveThrottleIdentity(request);
356
- const path = new URL(request.url).pathname;
357
- const throttleKey = `${prefix}${identity}:${path}`;
358
- const attempts = Number(await client.incr(throttleKey));
359
- if (attempts === 1) {
360
- await client.expire(throttleKey, options.decaySeconds);
464
+ const key = redisThrottleKey(request, prefix, resolveThrottleIdentity(request));
465
+ let attempts;
466
+ try {
467
+ attempts = await consume(key, options.decaySeconds);
468
+ } catch {
469
+ return throttleUnavailableResponse();
361
470
  }
362
471
  const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
363
472
  if (attempts > maxAttempts) {
@@ -367,6 +476,10 @@ function createThrottleMiddleware(options) {
367
476
  };
368
477
  }
369
478
  export {
479
+ consumeRedisThrottle,
480
+ createRedisThrottleConsumer,
370
481
  createThrottleMiddleware,
371
- resolveThrottleIdentity
482
+ redisThrottleKey,
483
+ resolveThrottleIdentity,
484
+ throttleUnavailableResponse
372
485
  };