@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.
- package/CHANGELOG.md +33 -0
- package/dist/core/cache/createCacheStore.d.ts +3 -1
- package/dist/core/cache/redisCacheStore.d.ts +21 -8
- package/dist/core/cache/repository.d.ts +3 -2
- package/dist/core/cache/store.d.ts +4 -1
- package/dist/core/cache/taggedCache.d.ts +1 -1
- package/dist/core/contracts/di.d.ts +4 -2
- package/dist/core/contracts/serviceTokens.d.ts +6 -1
- package/dist/core/database/baseRepository.d.ts +0 -1
- package/dist/core/database/index.d.ts +1 -1
- package/dist/core/database/model.d.ts +51 -22
- package/dist/core/database/repositoryQuery.d.ts +16 -1
- package/dist/core/database/types.d.ts +25 -2
- package/dist/core/database/whereBuilder.d.ts +5 -5
- package/dist/core/events/deferredModelEvents.d.ts +2 -1
- package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
- package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
- package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
- package/dist/core/http/throttleMiddleware.d.ts +24 -6
- package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
- package/dist/core/queue/index.d.ts +7 -0
- package/dist/core/queue/redisQueue.d.ts +1 -0
- package/dist/core/queue/resilientQueue.d.ts +2 -2
- package/dist/core/runtime/applicationRegistry.d.ts +2 -1
- package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
- package/dist/core/scheduler/leases.d.ts +40 -0
- package/dist/core/scheduler/schedule.d.ts +29 -5
- package/dist/core/tenant/tenantContext.d.ts +5 -4
- package/dist/core/tenant/tenantMiddleware.d.ts +7 -1
- package/dist/core/tracing/otel.d.ts +28 -26
- package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
- package/dist/entries/audit/exportAuditLogs.js +14 -2
- package/dist/entries/auth/basicAuthGuard.js +80 -0
- package/dist/entries/auth/jwtGuard.js +80 -0
- package/dist/entries/auth/scimAuthMiddleware.js +17 -7
- package/dist/entries/auth/sessionGuard.js +80 -0
- package/dist/entries/cache/createCacheStore.js +365 -119
- package/dist/entries/cache/repository.js +5 -0
- package/dist/entries/contracts/serviceTokens.js +83 -0
- package/dist/entries/database/factory.js +0 -3
- package/dist/entries/database/model.js +1 -2486
- package/dist/entries/database/repositoryQuery.js +67 -9
- package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
- package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
- package/dist/entries/http/scimThrottleMiddleware.js +576 -54
- package/dist/entries/http/throttleMiddleware.js +65 -16
- package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
- package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
- package/dist/entries/queue/createAppQueue.js +67 -6
- package/dist/entries/queue/publicQueue.js +67 -6
- package/dist/entries/queue/queueMetrics.js +67 -6
- package/dist/entries/queue/redisQueue.js +3 -0
- package/dist/entries/queue.js +30 -5
- package/dist/entries/scheduler/schedule.js +1 -72
- package/dist/entries/tenant/databaseTenantContext.js +14 -2
- package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
- package/dist/entries/tracing/tracingMiddleware.js +186 -454
- package/dist/framework/public-api.d.ts +13 -9
- package/dist/index.js +1745 -633
- package/dist/types/services.d.ts +3 -3
- 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/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
274
|
+
function currentRequestMeta2() {
|
|
275
|
+
return requestMetaContext.getStore() ?? {
|
|
276
|
+
ipAddress: null,
|
|
277
|
+
userAgent: null
|
|
278
|
+
};
|
|
100
279
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
function sanitizeNestedQuery(search) {
|
|
344
|
+
if (!search) {
|
|
345
|
+
return "";
|
|
111
346
|
}
|
|
112
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
};
|