@getstrata/core 2.0.9 → 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 +37 -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/container.d.ts +28 -10
- package/dist/core/contracts/di.d.ts +7 -4
- 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 +123 -42
- 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/middleware.d.ts +2 -2
- package/dist/core/http/response.d.ts +2 -0
- package/dist/core/http/routeMiddleware.d.ts +3 -2
- 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/container.js +8 -1
- package/dist/entries/contracts/serviceTokens.js +83 -0
- package/dist/entries/database/factory.js +0 -3
- package/dist/entries/database/model.js +1 -2465
- 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/routeMiddleware.js +1 -4
- 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 +14 -10
- package/dist/index.js +1770 -638
- package/dist/types/services.d.ts +3 -3
- package/package.json +17 -8
|
@@ -815,6 +815,7 @@ class RepositoryQuery {
|
|
|
815
815
|
queryOptions;
|
|
816
816
|
eagerLoads = [];
|
|
817
817
|
whereNodes = [];
|
|
818
|
+
protectedWhere = [];
|
|
818
819
|
constructor(repository, whereClause = {}, queryOptions = {}) {
|
|
819
820
|
this.repository = repository;
|
|
820
821
|
this.whereClause = whereClause;
|
|
@@ -824,10 +825,23 @@ class RepositoryQuery {
|
|
|
824
825
|
if (typeof input === "function") {
|
|
825
826
|
const builder = new WhereBuilder;
|
|
826
827
|
input(builder);
|
|
827
|
-
|
|
828
|
+
if (builder.nodes.length > 0)
|
|
829
|
+
this.whereNodes.push({ kind: "and", group: builder.nodes });
|
|
828
830
|
return this;
|
|
829
831
|
}
|
|
830
|
-
this.
|
|
832
|
+
this.whereNodes.push({ kind: "and", where: { ...input } });
|
|
833
|
+
return this;
|
|
834
|
+
}
|
|
835
|
+
protectWhere() {
|
|
836
|
+
const nodes = [];
|
|
837
|
+
if (Object.keys(this.whereClause).length > 0) {
|
|
838
|
+
nodes.push({ kind: "and", where: this.whereClause });
|
|
839
|
+
}
|
|
840
|
+
nodes.push(...this.whereNodes);
|
|
841
|
+
if (nodes.length > 0)
|
|
842
|
+
this.protectedWhere.push({ kind: "and", group: nodes });
|
|
843
|
+
this.whereClause = {};
|
|
844
|
+
this.whereNodes.length = 0;
|
|
831
845
|
return this;
|
|
832
846
|
}
|
|
833
847
|
orWhere(input) {
|
|
@@ -941,9 +955,10 @@ class RepositoryQuery {
|
|
|
941
955
|
});
|
|
942
956
|
return this;
|
|
943
957
|
}
|
|
944
|
-
withMorphTo(as, relation, repositoriesByType, options = {}) {
|
|
958
|
+
withMorphTo(as, relation, repositoriesByType, options = {}, optionsByType) {
|
|
945
959
|
this.eagerLoads.push({
|
|
946
960
|
kind: "morphTo",
|
|
961
|
+
morphOptions: optionsByType,
|
|
947
962
|
as,
|
|
948
963
|
relation,
|
|
949
964
|
repository: this.repository,
|
|
@@ -1014,6 +1029,19 @@ class RepositoryQuery {
|
|
|
1014
1029
|
}
|
|
1015
1030
|
return row[column];
|
|
1016
1031
|
}
|
|
1032
|
+
async project(columns, limit) {
|
|
1033
|
+
if (columns.length === 0)
|
|
1034
|
+
throw new RangeError("A projection requires at least one column.");
|
|
1035
|
+
const table = this.repository.getTable();
|
|
1036
|
+
for (const column of columns)
|
|
1037
|
+
if (!table.columns.includes(column))
|
|
1038
|
+
throw new TypeError(`Unknown projection column: ${column}`);
|
|
1039
|
+
return await this.repository.findAll({
|
|
1040
|
+
...this.buildOptions(),
|
|
1041
|
+
...limit === undefined ? {} : { limit },
|
|
1042
|
+
select: uniqueColumnSelect(table.name, columns)
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1017
1045
|
async attachToRows(rows) {
|
|
1018
1046
|
return await this.attach(rows);
|
|
1019
1047
|
}
|
|
@@ -1024,11 +1052,24 @@ class RepositoryQuery {
|
|
|
1024
1052
|
perPage: options.perPage
|
|
1025
1053
|
});
|
|
1026
1054
|
}
|
|
1055
|
+
async chunk(count, callback) {
|
|
1056
|
+
await this.repository.chunk(count, async (rows) => callback(await this.attach(rows)), this.buildOptions());
|
|
1057
|
+
}
|
|
1058
|
+
async cursorPaginate(options) {
|
|
1059
|
+
const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
|
|
1060
|
+
return { ...page, data: await this.attach(page.data) };
|
|
1061
|
+
}
|
|
1062
|
+
getOptions() {
|
|
1063
|
+
return this.buildOptions();
|
|
1064
|
+
}
|
|
1027
1065
|
buildOptions() {
|
|
1066
|
+
if (this.protectedWhere.length === 0) {
|
|
1067
|
+
return { ...this.queryOptions, where: this.whereClause, whereNodes: this.whereNodes };
|
|
1068
|
+
}
|
|
1028
1069
|
return {
|
|
1029
1070
|
...this.queryOptions,
|
|
1030
|
-
where:
|
|
1031
|
-
whereNodes: this.whereNodes
|
|
1071
|
+
where: {},
|
|
1072
|
+
whereNodes: [...this.protectedWhere, { kind: "and", group: [...this.whereNodes] }]
|
|
1032
1073
|
};
|
|
1033
1074
|
}
|
|
1034
1075
|
addJoin(type, left, right) {
|
|
@@ -1105,10 +1146,27 @@ class RepositoryQuery {
|
|
|
1105
1146
|
}
|
|
1106
1147
|
if (load.kind === "morphTo") {
|
|
1107
1148
|
const relation = load.relation;
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1149
|
+
const repositories = load.morphRepositories ?? new Map;
|
|
1150
|
+
const partitions = new Map;
|
|
1151
|
+
rows.forEach((row, index) => {
|
|
1152
|
+
const type = String(row[relation.morphTypeKey]);
|
|
1153
|
+
const target = result[index];
|
|
1154
|
+
if (!target || !repositories.has(type))
|
|
1155
|
+
return;
|
|
1156
|
+
const partition = partitions.get(type) ?? { rows: [], targets: [] };
|
|
1157
|
+
partition.rows.push(row);
|
|
1158
|
+
partition.targets.push(target);
|
|
1159
|
+
partitions.set(type, partition);
|
|
1160
|
+
});
|
|
1161
|
+
await Promise.all([...partitions.entries()].map(async ([type, partition]) => {
|
|
1162
|
+
const repository = repositories.get(type);
|
|
1163
|
+
if (!repository)
|
|
1164
|
+
return;
|
|
1165
|
+
const grouped = await this.repository.loadMorphToForChildren(partition.rows, relation, new Map([[type, repository]]), load.morphOptions?.get(type) ?? load.options);
|
|
1166
|
+
for (const row of partition.targets) {
|
|
1167
|
+
row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
|
|
1168
|
+
}
|
|
1169
|
+
}));
|
|
1112
1170
|
return;
|
|
1113
1171
|
}
|
|
1114
1172
|
const relation = load.relation;
|
|
@@ -1,29 +1,95 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/bodySizeLimitMiddleware.ts
|
|
3
3
|
import { PayloadTooLargeError } from "@getstrata/core/errors/http";
|
|
4
|
+
var guards = new WeakMap;
|
|
5
|
+
function guardBody(request, maxBytes) {
|
|
6
|
+
const existing = guards.get(request);
|
|
7
|
+
if (existing && existing.maxBytes <= maxBytes)
|
|
8
|
+
return existing;
|
|
9
|
+
const state = { maxBytes, exceeded: false };
|
|
10
|
+
const body = request.body;
|
|
11
|
+
if (!body)
|
|
12
|
+
return state;
|
|
13
|
+
let consumed = 0;
|
|
14
|
+
const stream = body.pipeThrough(new TransformStream({
|
|
15
|
+
transform(chunk, controller) {
|
|
16
|
+
const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
17
|
+
consumed += bytes.byteLength;
|
|
18
|
+
if (consumed > maxBytes) {
|
|
19
|
+
state.exceeded = true;
|
|
20
|
+
throw new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
|
|
21
|
+
}
|
|
22
|
+
controller.enqueue(bytes);
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
const bounded = new Request(request, { body: stream });
|
|
26
|
+
const decorateClone = (clone) => {
|
|
27
|
+
for (const field of ["params", "cookies"]) {
|
|
28
|
+
if (field in request)
|
|
29
|
+
Object.defineProperty(clone, field, { value: Reflect.get(request, field) });
|
|
30
|
+
}
|
|
31
|
+
const nativeClone = () => Reflect.apply(Request.prototype.clone, clone, []);
|
|
32
|
+
Object.defineProperty(clone, "clone", {
|
|
33
|
+
value: () => decorateClone(nativeClone())
|
|
34
|
+
});
|
|
35
|
+
guards.set(clone, state);
|
|
36
|
+
return clone;
|
|
37
|
+
};
|
|
38
|
+
const descriptors = {
|
|
39
|
+
body: { configurable: true, get: () => bounded.body },
|
|
40
|
+
bodyUsed: { configurable: true, get: () => bounded.bodyUsed },
|
|
41
|
+
clone: {
|
|
42
|
+
configurable: true,
|
|
43
|
+
value: () => decorateClone(Reflect.apply(Request.prototype.clone, bounded, []))
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
for (const method of [
|
|
47
|
+
"text",
|
|
48
|
+
"json",
|
|
49
|
+
"formData",
|
|
50
|
+
"arrayBuffer",
|
|
51
|
+
"blob",
|
|
52
|
+
"bytes",
|
|
53
|
+
"textStream"
|
|
54
|
+
]) {
|
|
55
|
+
descriptors[method] = { configurable: true, value: bounded[method].bind(bounded) };
|
|
56
|
+
}
|
|
57
|
+
Object.defineProperties(request, descriptors);
|
|
58
|
+
guards.set(request, state);
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
4
61
|
var DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
5
62
|
function resolveMaxBodyBytes() {
|
|
6
63
|
const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
|
|
7
64
|
if (!raw) {
|
|
8
65
|
return DEFAULT_MAX_BODY_BYTES;
|
|
9
66
|
}
|
|
10
|
-
const parsed = Number
|
|
11
|
-
if (!Number.
|
|
67
|
+
const parsed = Number(raw);
|
|
68
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
12
69
|
return DEFAULT_MAX_BODY_BYTES;
|
|
13
70
|
}
|
|
14
71
|
return parsed;
|
|
15
72
|
}
|
|
16
73
|
function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
|
|
74
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
|
|
75
|
+
throw new RangeError("Body limit must be a positive safe integer.");
|
|
17
76
|
return async (request, next) => {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
77
|
+
const tooLarge = () => {
|
|
78
|
+
const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
|
|
79
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
80
|
+
};
|
|
81
|
+
const length = Number(request.headers.get("content-length"));
|
|
82
|
+
if (Number.isFinite(length) && length > maxBytes)
|
|
83
|
+
return tooLarge();
|
|
84
|
+
const state = guardBody(request, maxBytes);
|
|
85
|
+
try {
|
|
86
|
+
const response = await next();
|
|
87
|
+
return state.exceeded ? tooLarge() : response;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (state.exceeded || error instanceof PayloadTooLargeError)
|
|
90
|
+
return tooLarge();
|
|
91
|
+
throw error;
|
|
25
92
|
}
|
|
26
|
-
return await next();
|
|
27
93
|
};
|
|
28
94
|
}
|
|
29
95
|
export {
|
|
@@ -1,4 +1,98 @@
|
|
|
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/runtime/boundedThrottleStore.ts
|
|
30
|
+
class BoundedThrottleStore {
|
|
31
|
+
decayMs;
|
|
32
|
+
now;
|
|
33
|
+
buckets = new Map;
|
|
34
|
+
maxBuckets;
|
|
35
|
+
pruneBatchSize;
|
|
36
|
+
disposed = false;
|
|
37
|
+
pruned = 0;
|
|
38
|
+
constructor(decayMs, options = {}, now = () => performance.now()) {
|
|
39
|
+
this.decayMs = decayMs;
|
|
40
|
+
this.now = now;
|
|
41
|
+
this.maxBuckets = options.maxBuckets ?? 1e4;
|
|
42
|
+
this.pruneBatchSize = options.pruneBatchSize ?? 32;
|
|
43
|
+
if (!Number.isFinite(decayMs) || decayMs < 0 || decayMs > 2147483647) {
|
|
44
|
+
throw new Error("Invalid memory throttle window.");
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isSafeInteger(this.maxBuckets) || this.maxBuckets < 1 || this.maxBuckets > 1e6 || !Number.isSafeInteger(this.pruneBatchSize) || this.pruneBatchSize < 1 || this.pruneBatchSize > 1024) {
|
|
47
|
+
throw new Error("Invalid memory throttle storage bounds.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
consume(key) {
|
|
51
|
+
this.pruned = 0;
|
|
52
|
+
if (this.disposed)
|
|
53
|
+
return null;
|
|
54
|
+
const now = this.now();
|
|
55
|
+
const entries = this.buckets.entries();
|
|
56
|
+
for (let i = 0;i < this.pruneBatchSize; i++) {
|
|
57
|
+
const oldest = entries.next().value;
|
|
58
|
+
if (!oldest || oldest[1].resetAt > now)
|
|
59
|
+
break;
|
|
60
|
+
this.buckets.delete(oldest[0]);
|
|
61
|
+
this.pruned++;
|
|
62
|
+
}
|
|
63
|
+
const existing = this.buckets.get(key);
|
|
64
|
+
if (existing && existing.resetAt > now) {
|
|
65
|
+
existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + 1);
|
|
66
|
+
return existing.count;
|
|
67
|
+
}
|
|
68
|
+
this.buckets.delete(key);
|
|
69
|
+
if (this.buckets.size >= this.maxBuckets)
|
|
70
|
+
return null;
|
|
71
|
+
this.buckets.set(key, { count: 1, resetAt: now + this.decayMs });
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
clear() {
|
|
75
|
+
this.buckets.clear();
|
|
76
|
+
}
|
|
77
|
+
dispose() {
|
|
78
|
+
this.disposed = true;
|
|
79
|
+
this.clear();
|
|
80
|
+
}
|
|
81
|
+
stats() {
|
|
82
|
+
return {
|
|
83
|
+
retainedBuckets: this.buckets.size,
|
|
84
|
+
prunedOnLastConsume: this.pruned,
|
|
85
|
+
disposed: this.disposed
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ../../src/core/http/throttleMiddleware.ts
|
|
91
|
+
import { createHash } from "crypto";
|
|
92
|
+
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
93
|
+
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
94
|
+
var {RedisClient } = globalThis.Bun;
|
|
95
|
+
|
|
2
96
|
// ../../src/core/runtime/appEnv.ts
|
|
3
97
|
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
4
98
|
function normalizeEnvValue(value) {
|
|
@@ -117,6 +211,19 @@ function readClientIp(request, env = process.env) {
|
|
|
117
211
|
return currentRequestMeta().ipAddress ?? undefined;
|
|
118
212
|
}
|
|
119
213
|
|
|
214
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
215
|
+
init_asyncContextStore();
|
|
216
|
+
var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
|
|
217
|
+
function runWithRequestMeta(meta, callback) {
|
|
218
|
+
return requestMetaContext.run(meta, callback);
|
|
219
|
+
}
|
|
220
|
+
function currentRequestMeta2() {
|
|
221
|
+
return requestMetaContext.getStore() ?? {
|
|
222
|
+
ipAddress: null,
|
|
223
|
+
userAgent: null
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
120
227
|
// ../../src/core/runtime/frontendMode.ts
|
|
121
228
|
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
122
229
|
var DEFAULT_SPA_PREFIX = "/app";
|
|
@@ -157,7 +264,7 @@ function readSpaPrefix() {
|
|
|
157
264
|
}
|
|
158
265
|
|
|
159
266
|
// ../../src/core/view/webErrorView.ts
|
|
160
|
-
import { currentRequestMeta as
|
|
267
|
+
import { currentRequestMeta as currentRequestMeta3 } from "@getstrata/core/http/requestMetaContext";
|
|
161
268
|
|
|
162
269
|
// ../../src/core/http/safeInternalPath.ts
|
|
163
270
|
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
@@ -276,7 +383,7 @@ async function renderWebErrorHtml(input) {
|
|
|
276
383
|
try {
|
|
277
384
|
return await render({
|
|
278
385
|
...input,
|
|
279
|
-
request: input.request ??
|
|
386
|
+
request: input.request ?? currentRequestMeta3().request
|
|
280
387
|
});
|
|
281
388
|
} catch {
|
|
282
389
|
return renderKernelErrorChrome(input);
|
|
@@ -332,33 +439,181 @@ async function tooManyRequestsResponse(request, message, decaySeconds) {
|
|
|
332
439
|
return new Response(html.body, { status: 429, headers });
|
|
333
440
|
}
|
|
334
441
|
|
|
442
|
+
// ../../src/core/http/throttleMiddleware.ts
|
|
443
|
+
function resolveThrottleIdentity(request) {
|
|
444
|
+
const user = currentAuthUser();
|
|
445
|
+
if (user?.tokenId !== undefined) {
|
|
446
|
+
return `token:${user.tokenId}`;
|
|
447
|
+
}
|
|
448
|
+
if (user) {
|
|
449
|
+
return `user:${user.id}`;
|
|
450
|
+
}
|
|
451
|
+
return readClientIp(request) ?? "unknown";
|
|
452
|
+
}
|
|
453
|
+
var CONSUME_THROTTLE = `
|
|
454
|
+
local count = redis.call('INCR', KEYS[1])
|
|
455
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
456
|
+
if ttl < 0 then
|
|
457
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
458
|
+
end
|
|
459
|
+
return count
|
|
460
|
+
`;
|
|
461
|
+
function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
|
|
462
|
+
const bucket = JSON.stringify([
|
|
463
|
+
tenantId,
|
|
464
|
+
request.method,
|
|
465
|
+
currentRequestMeta2().routeTemplate ?? "__unmatched__",
|
|
466
|
+
identity
|
|
467
|
+
]);
|
|
468
|
+
return namespacedRedisKey(`${prefix}:${createHash("sha256").update(bucket).digest("hex")}`);
|
|
469
|
+
}
|
|
470
|
+
async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
|
|
471
|
+
const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
|
|
472
|
+
if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
473
|
+
throw new Error("Invalid throttle expiry or command timeout.");
|
|
474
|
+
}
|
|
475
|
+
if (signal?.aborted)
|
|
476
|
+
throw new Error("Throttle consumer is disposed.");
|
|
477
|
+
let onAbort;
|
|
478
|
+
let timer;
|
|
479
|
+
try {
|
|
480
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
481
|
+
onAbort = () => reject(new Error("Throttle consumer is disposed."));
|
|
482
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
483
|
+
});
|
|
484
|
+
const result = await Promise.race([
|
|
485
|
+
cancelled,
|
|
486
|
+
client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
|
|
487
|
+
new Promise((_resolve, reject) => {
|
|
488
|
+
timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
|
|
489
|
+
})
|
|
490
|
+
]);
|
|
491
|
+
const count = Number(result);
|
|
492
|
+
if (!Number.isSafeInteger(count) || count < 1)
|
|
493
|
+
throw new Error("Invalid throttle store response.");
|
|
494
|
+
return count;
|
|
495
|
+
} finally {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
if (onAbort)
|
|
498
|
+
signal?.removeEventListener("abort", onAbort);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function createRedisThrottleConsumer(options) {
|
|
502
|
+
let ownedClient;
|
|
503
|
+
const controller = new AbortController;
|
|
504
|
+
const consume = async (key, decaySeconds) => {
|
|
505
|
+
if (controller.signal.aborted)
|
|
506
|
+
throw new Error("Throttle consumer is disposed.");
|
|
507
|
+
if (!options.redisClient && !ownedClient) {
|
|
508
|
+
ownedClient = new RedisClient(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
|
|
509
|
+
}
|
|
510
|
+
const client = options.redisClient ?? ownedClient;
|
|
511
|
+
if (!client)
|
|
512
|
+
throw new Error("Throttle client is required.");
|
|
513
|
+
try {
|
|
514
|
+
const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
|
|
515
|
+
if (controller.signal.aborted)
|
|
516
|
+
throw new Error("Throttle consumer is disposed.");
|
|
517
|
+
return attempts;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (client === ownedClient) {
|
|
520
|
+
ownedClient.close();
|
|
521
|
+
ownedClient = undefined;
|
|
522
|
+
}
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
return Object.assign(consume, {
|
|
527
|
+
isDisposed: () => controller.signal.aborted,
|
|
528
|
+
dispose() {
|
|
529
|
+
if (controller.signal.aborted)
|
|
530
|
+
return;
|
|
531
|
+
controller.abort();
|
|
532
|
+
const client = ownedClient;
|
|
533
|
+
ownedClient = undefined;
|
|
534
|
+
client?.close();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
function throttleUnavailableResponse() {
|
|
539
|
+
return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
|
|
540
|
+
status: 503,
|
|
541
|
+
headers: { "retry-after": "1" }
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
|
|
545
|
+
const limit = options.quotaPolicy ? options.quotaPolicy({
|
|
546
|
+
request,
|
|
547
|
+
tenant: currentTenant(),
|
|
548
|
+
identity,
|
|
549
|
+
routeTemplate: currentRequestMeta2().routeTemplate ?? "__unmatched__",
|
|
550
|
+
maxAttempts: options.maxAttempts
|
|
551
|
+
}) : options.maxAttempts;
|
|
552
|
+
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
553
|
+
throw new Error("Invalid application quota.");
|
|
554
|
+
}
|
|
555
|
+
return limit;
|
|
556
|
+
}
|
|
557
|
+
function createThrottleMiddleware(options) {
|
|
558
|
+
if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
|
|
559
|
+
throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
|
|
560
|
+
}
|
|
561
|
+
const consume = createRedisThrottleConsumer(options);
|
|
562
|
+
const prefix = options.keyPrefix ?? "throttle";
|
|
563
|
+
const middleware = async (request, next) => {
|
|
564
|
+
const identity = resolveThrottleIdentity(request);
|
|
565
|
+
let maxAttempts;
|
|
566
|
+
try {
|
|
567
|
+
maxAttempts = resolveThrottleQuota(request, options, identity);
|
|
568
|
+
} catch {
|
|
569
|
+
return throttleUnavailableResponse();
|
|
570
|
+
}
|
|
571
|
+
const key = redisThrottleKey(request, prefix, identity);
|
|
572
|
+
let attempts;
|
|
573
|
+
try {
|
|
574
|
+
attempts = await consume(key, options.decaySeconds);
|
|
575
|
+
} catch {
|
|
576
|
+
return throttleUnavailableResponse();
|
|
577
|
+
}
|
|
578
|
+
if (consume.isDisposed())
|
|
579
|
+
return throttleUnavailableResponse();
|
|
580
|
+
if (attempts > maxAttempts) {
|
|
581
|
+
return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
|
|
582
|
+
}
|
|
583
|
+
return await next();
|
|
584
|
+
};
|
|
585
|
+
return Object.assign(middleware, { dispose: () => consume.dispose() });
|
|
586
|
+
}
|
|
587
|
+
|
|
335
588
|
// ../../src/core/http/memoryThrottleMiddleware.ts
|
|
336
|
-
var
|
|
589
|
+
var resetGeneration = 0;
|
|
337
590
|
function createMemoryThrottleMiddleware(options) {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const existing = buckets.get(key);
|
|
347
|
-
if (!existing || existing.resetAt <= now) {
|
|
348
|
-
buckets.set(key, { count: 1, resetAt: now + options.decaySeconds * 1000 });
|
|
349
|
-
return await next();
|
|
591
|
+
const store = new BoundedThrottleStore(options.decaySeconds * 1000, options);
|
|
592
|
+
if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
|
|
593
|
+
throw new Error("Invalid memory throttle limit.");
|
|
594
|
+
let generation = resetGeneration;
|
|
595
|
+
const middleware = async (request, next) => {
|
|
596
|
+
if (generation !== resetGeneration) {
|
|
597
|
+
store.clear();
|
|
598
|
+
generation = resetGeneration;
|
|
350
599
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
600
|
+
let maxAttempts;
|
|
601
|
+
try {
|
|
602
|
+
maxAttempts = resolveThrottleQuota(request, options);
|
|
603
|
+
} catch {
|
|
604
|
+
return throttleUnavailableResponse();
|
|
354
605
|
}
|
|
606
|
+
const attempts = store.consume(redisThrottleKey(request, options.keyPrefix ?? "memory-throttle", resolveThrottleIdentity(request)));
|
|
607
|
+
if (attempts === null)
|
|
608
|
+
return throttleUnavailableResponse();
|
|
609
|
+
if (attempts > maxAttempts)
|
|
610
|
+
return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
|
|
355
611
|
return await next();
|
|
356
612
|
};
|
|
613
|
+
return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
|
|
357
614
|
}
|
|
358
615
|
function resetMemoryThrottleForTests() {
|
|
359
|
-
|
|
360
|
-
buckets.clear();
|
|
361
|
-
}
|
|
616
|
+
resetGeneration++;
|
|
362
617
|
}
|
|
363
618
|
export {
|
|
364
619
|
createMemoryThrottleMiddleware,
|
|
@@ -62,10 +62,7 @@ function composeMiddleware(...middleware) {
|
|
|
62
62
|
|
|
63
63
|
// ../../src/core/http/routeMiddleware.ts
|
|
64
64
|
function withMiddleware(...middleware) {
|
|
65
|
-
|
|
66
|
-
return (handler) => {
|
|
67
|
-
return wrap(handler);
|
|
68
|
-
};
|
|
65
|
+
return composeMiddleware(...middleware);
|
|
69
66
|
}
|
|
70
67
|
export {
|
|
71
68
|
withMiddleware
|