@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.
- package/CHANGELOG.md +23 -0
- package/dist/core/database/transaction.d.ts +2 -1
- package/dist/core/database/transactionControl.d.ts +5 -0
- package/dist/core/events/deferredModelEvents.d.ts +3 -0
- package/dist/core/events/index.d.ts +1 -0
- package/dist/core/http/loginThrottleMiddleware.d.ts +3 -0
- package/dist/core/http/metricsMiddleware.d.ts +1 -2
- package/dist/core/http/requestMetaContext.d.ts +2 -0
- package/dist/core/http/throttleMiddleware.d.ts +11 -2
- package/dist/core/metrics/prometheus.d.ts +11 -1
- package/dist/core/queue/jobRunner.d.ts +3 -1
- package/dist/core/queue/redisQueue.d.ts +20 -3
- package/dist/core/tenant/databaseTenantContext.d.ts +5 -1
- package/dist/entries/audit/exportAuditLogs.js +751 -97
- package/dist/entries/auth/accessControl.js +16 -2
- package/dist/entries/auth/basicAuthGuard.js +271 -224
- package/dist/entries/auth/membershipMiddleware.js +17 -0
- package/dist/entries/auth/membershipScope.js +21 -6
- package/dist/entries/auth/membershipService.js +20 -5
- package/dist/entries/auth/oauth/samlProvider.js +187 -142
- package/dist/entries/auth/oneTimeToken.js +142 -123
- package/dist/entries/auth/passwordLogin.js +212 -191
- package/dist/entries/auth/policy.js +16 -2
- package/dist/entries/auth/saml/samlServiceProvider.js +187 -142
- package/dist/entries/auth/scimAuthMiddleware.js +732 -64
- package/dist/entries/database/migrations.js +144 -125
- package/dist/entries/database/schema.js +58 -39
- package/dist/entries/http/csrfMiddleware.js +28 -14
- package/dist/entries/http/csrfToken.js +28 -14
- package/dist/entries/http/flashMiddleware.js +28 -14
- package/dist/entries/http/metricsMiddleware.js +124 -27
- package/dist/entries/http/response.js +107 -0
- package/dist/entries/http/routeMiddleware.js +40 -0
- package/dist/entries/http/throttleMiddleware.js +124 -11
- package/dist/entries/jobs/exportAuditLogsJob.js +751 -97
- package/dist/entries/metrics/prometheus.js +71 -11
- package/dist/entries/queue/createAppQueue.js +215 -21
- package/dist/entries/queue/jobRunner.js +3 -2
- package/dist/entries/queue/publicQueue.js +215 -21
- package/dist/entries/queue/queueMetrics.js +215 -21
- package/dist/entries/queue/redisQueue.js +223 -22
- package/dist/entries/runtime/asyncContextStore.js +16 -0
- package/dist/entries/tenant/databaseTenantContext.js +666 -11
- package/dist/entries/tenant/tenancyConfig.js +19 -1
- package/dist/entries/tenant/tenantDatabaseScope.js +688 -18
- package/dist/entries/tracing/tracingMiddleware.js +28 -14
- package/dist/framework/public-api.d.ts +3 -3
- package/dist/index.js +1344 -651
- 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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
25
|
-
for (const [key,
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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/
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
353
|
-
const prefix = options.keyPrefix ??
|
|
461
|
+
const consume = createRedisThrottleConsumer(options);
|
|
462
|
+
const prefix = options.keyPrefix ?? "throttle";
|
|
354
463
|
return async (request, next) => {
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
482
|
+
redisThrottleKey,
|
|
483
|
+
resolveThrottleIdentity,
|
|
484
|
+
throttleUnavailableResponse
|
|
372
485
|
};
|