@spfn/core 0.2.0-beta.9 → 0.3.0-beta.10
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/LICENSE +1 -1
- package/README.md +693 -279
- package/dist/app-config/index.d.ts +96 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +430 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/boss-BiDCbbAQ.d.ts +337 -0
- package/dist/cache/index.js +42 -30
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +121 -13
- package/dist/codegen/index.js +212 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +576 -30
- package/dist/config/index.js +136 -17
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +221 -0
- package/dist/contract/index.js +558 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.d.ts +704 -94
- package/dist/db/index.js +1105 -260
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
- package/dist/env/index.d.ts +26 -2
- package/dist/env/index.js +15 -5
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +26 -19
- package/dist/env/loader.js +55 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +40 -7
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +33 -3
- package/dist/event/index.js +24 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +55 -3
- package/dist/event/sse/client.js +164 -45
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +12 -5
- package/dist/event/sse/index.js +280 -32
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +59 -0
- package/dist/event/ws/client.js +273 -0
- package/dist/event/ws/client.js.map +1 -0
- package/dist/event/ws/index.d.ts +94 -0
- package/dist/event/ws/index.js +272 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +265 -74
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +352 -10
- package/dist/middleware/index.js +1079 -13
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +39 -3
- package/dist/nextjs/index.js +82 -29
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +69 -52
- package/dist/nextjs/server.js +140 -60
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +208 -0
- package/dist/ops/index.js +800 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/route/index.d.ts +10 -694
- package/dist/route/index.js +112 -23
- package/dist/route/index.js.map +1 -1
- package/dist/route-builder-2ani2jEI.d.ts +553 -0
- package/dist/router-DJdpwuB6.d.ts +127 -0
- package/dist/security/index.d.ts +83 -0
- package/dist/security/index.js +173 -0
- package/dist/security/index.js.map +1 -0
- package/dist/server/index.d.ts +619 -31
- package/dist/server/index.js +2583 -360
- package/dist/server/index.js.map +1 -1
- package/dist/token-manager-BT5EnUAR.d.ts +278 -0
- package/dist/types-2AbaW4Ie.d.ts +205 -0
- package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
- package/dist/types-ClQVomgV.d.ts +115 -0
- package/dist/types-ZQODsBft.d.ts +282 -0
- package/docs/file-upload.md +195 -333
- package/package.json +250 -208
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +741 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/dist/boss-DI1r4kTS.d.ts +0 -244
- package/dist/router-Di7ENoah.d.ts +0 -151
- package/dist/types-B-e_f2dQ.d.ts +0 -121
- package/docs/cache.md +0 -133
- package/docs/codegen.md +0 -74
- package/docs/database.md +0 -346
- package/docs/entity.md +0 -539
- package/docs/env.md +0 -477
- package/docs/errors.md +0 -319
- package/docs/event.md +0 -116
- package/docs/job.md +0 -131
- package/docs/logger.md +0 -108
- package/docs/middleware.md +0 -337
- package/docs/nextjs.md +0 -241
- package/docs/repository.md +0 -496
- package/docs/route.md +0 -497
- package/docs/server.md +0 -307
package/dist/middleware/index.js
CHANGED
|
@@ -1,10 +1,89 @@
|
|
|
1
|
+
import { randomBytes, createHmac, createHash, timingSafeEqual } from 'crypto';
|
|
1
2
|
import { SerializableError } from '@spfn/core/errors';
|
|
2
3
|
import { logger } from '@spfn/core/logger';
|
|
3
4
|
import { env } from '@spfn/core/config';
|
|
4
|
-
import { randomBytes } from 'crypto';
|
|
5
5
|
|
|
6
6
|
// src/middleware/error-handler.ts
|
|
7
7
|
var errorLogger = logger.child("@spfn/core:error-handler");
|
|
8
|
+
function withEnvelope(body, c) {
|
|
9
|
+
return {
|
|
10
|
+
...body,
|
|
11
|
+
error: {
|
|
12
|
+
code: body.__type,
|
|
13
|
+
message: body.message,
|
|
14
|
+
requestId: resolveRequestId(c)
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function resolveRequestId(c) {
|
|
19
|
+
const existing = c.get("requestId");
|
|
20
|
+
return existing ?? randomBytes(16).toString("hex");
|
|
21
|
+
}
|
|
22
|
+
function extractCauseMessage(err) {
|
|
23
|
+
const cause = err.cause;
|
|
24
|
+
if (!cause) {
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
if (cause instanceof Error) {
|
|
28
|
+
return cause.message;
|
|
29
|
+
}
|
|
30
|
+
if (typeof cause === "string") {
|
|
31
|
+
return cause;
|
|
32
|
+
}
|
|
33
|
+
return String(cause);
|
|
34
|
+
}
|
|
35
|
+
var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "x-api-key", "x-auth-token"]);
|
|
36
|
+
function extractHeaders(c) {
|
|
37
|
+
const headers = {};
|
|
38
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
39
|
+
headers[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) ? "***" : value;
|
|
40
|
+
});
|
|
41
|
+
return headers;
|
|
42
|
+
}
|
|
43
|
+
var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set([
|
|
44
|
+
"token",
|
|
45
|
+
"access_token",
|
|
46
|
+
"refresh_token",
|
|
47
|
+
"id_token",
|
|
48
|
+
"code",
|
|
49
|
+
"secret",
|
|
50
|
+
"client_secret",
|
|
51
|
+
"password",
|
|
52
|
+
"passwd",
|
|
53
|
+
"pwd",
|
|
54
|
+
"api_key",
|
|
55
|
+
"apikey",
|
|
56
|
+
"key",
|
|
57
|
+
"signature",
|
|
58
|
+
"sig",
|
|
59
|
+
"session",
|
|
60
|
+
"sessionid",
|
|
61
|
+
"auth",
|
|
62
|
+
"authorization"
|
|
63
|
+
]);
|
|
64
|
+
function extractQuery(c) {
|
|
65
|
+
const query = c.req.query();
|
|
66
|
+
const masked = {};
|
|
67
|
+
for (const [key, value] of Object.entries(query)) {
|
|
68
|
+
masked[key] = SENSITIVE_QUERY_PARAMS.has(key.toLowerCase()) ? "***" : value;
|
|
69
|
+
}
|
|
70
|
+
return masked;
|
|
71
|
+
}
|
|
72
|
+
function buildOnErrorContext(c, statusCode) {
|
|
73
|
+
const auth = c.get("auth");
|
|
74
|
+
return {
|
|
75
|
+
statusCode,
|
|
76
|
+
path: c.req.path,
|
|
77
|
+
method: c.req.method,
|
|
78
|
+
requestId: c.get("requestId"),
|
|
79
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
80
|
+
userId: auth?.userId,
|
|
81
|
+
request: {
|
|
82
|
+
headers: extractHeaders(c),
|
|
83
|
+
query: extractQuery(c)
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
8
87
|
function logError(err, logData, includeStack) {
|
|
9
88
|
const logLevel = logData.statusCode >= 500 ? "error" : "warn";
|
|
10
89
|
if (includeStack) {
|
|
@@ -19,25 +98,48 @@ function isSerializableError(err) {
|
|
|
19
98
|
function ErrorHandler(options = {}) {
|
|
20
99
|
const {
|
|
21
100
|
includeStack = env.NODE_ENV !== "production",
|
|
22
|
-
enableLogging = true
|
|
101
|
+
enableLogging = true,
|
|
102
|
+
errorEnvelope = true,
|
|
103
|
+
onError
|
|
23
104
|
} = options;
|
|
105
|
+
const respond = (c, body, statusCode) => {
|
|
106
|
+
return c.json(
|
|
107
|
+
errorEnvelope ? withEnvelope(body, c) : body,
|
|
108
|
+
statusCode
|
|
109
|
+
);
|
|
110
|
+
};
|
|
24
111
|
return (err, c) => {
|
|
112
|
+
const path = c.req.path;
|
|
113
|
+
const method = c.req.method;
|
|
114
|
+
const causeMessage = extractCauseMessage(err);
|
|
25
115
|
if (isSerializableError(err)) {
|
|
26
116
|
const { statusCode: statusCode2 } = err;
|
|
27
117
|
if (enableLogging) {
|
|
28
118
|
logError(err, {
|
|
29
119
|
type: err.constructor.name,
|
|
30
120
|
message: err.message,
|
|
121
|
+
cause: causeMessage,
|
|
31
122
|
statusCode: statusCode2,
|
|
32
|
-
path
|
|
33
|
-
method
|
|
123
|
+
path,
|
|
124
|
+
method
|
|
34
125
|
}, includeStack);
|
|
35
126
|
}
|
|
127
|
+
if (onError) {
|
|
128
|
+
const ctx = buildOnErrorContext(c, statusCode2);
|
|
129
|
+
Promise.resolve(onError(err, ctx)).catch((e) => errorLogger.warn("onError callback failed", e));
|
|
130
|
+
}
|
|
131
|
+
if (err.internal === true && !includeStack) {
|
|
132
|
+
return respond(
|
|
133
|
+
c,
|
|
134
|
+
{ __type: err.constructor.name, message: "Internal server error" },
|
|
135
|
+
statusCode2
|
|
136
|
+
);
|
|
137
|
+
}
|
|
36
138
|
const serialized = err.toJSON();
|
|
37
139
|
if (includeStack && err.stack) {
|
|
38
140
|
serialized.stack = err.stack;
|
|
39
141
|
}
|
|
40
|
-
return c
|
|
142
|
+
return respond(c, serialized, statusCode2);
|
|
41
143
|
}
|
|
42
144
|
const errorWithCode = err;
|
|
43
145
|
const statusCode = errorWithCode.statusCode || 500;
|
|
@@ -45,23 +147,805 @@ function ErrorHandler(options = {}) {
|
|
|
45
147
|
logError(err, {
|
|
46
148
|
type: err.name || "Error",
|
|
47
149
|
message: err.message,
|
|
150
|
+
cause: causeMessage,
|
|
48
151
|
statusCode,
|
|
49
|
-
path
|
|
50
|
-
method
|
|
152
|
+
path,
|
|
153
|
+
method
|
|
51
154
|
}, includeStack);
|
|
52
155
|
}
|
|
156
|
+
if (onError) {
|
|
157
|
+
const ctx = buildOnErrorContext(c, statusCode);
|
|
158
|
+
Promise.resolve(onError(err, ctx)).catch((e) => errorLogger.warn("onError callback failed", e));
|
|
159
|
+
}
|
|
53
160
|
const response = {
|
|
54
161
|
__type: "Error",
|
|
55
|
-
message: err.message || "Internal Server Error"
|
|
162
|
+
message: includeStack ? err.message || "Internal Server Error" : "Internal Server Error"
|
|
56
163
|
};
|
|
164
|
+
if (causeMessage && includeStack) {
|
|
165
|
+
response.cause = causeMessage;
|
|
166
|
+
}
|
|
57
167
|
if (includeStack && err.stack) {
|
|
58
168
|
response.stack = err.stack;
|
|
59
169
|
}
|
|
60
|
-
return c
|
|
170
|
+
return respond(c, response, statusCode);
|
|
61
171
|
};
|
|
62
172
|
}
|
|
173
|
+
var PROXY_SIGNATURE_HEADER = "x-spfn-proxy-signature";
|
|
174
|
+
var PROXY_TIMESTAMP_HEADER = "x-spfn-proxy-timestamp";
|
|
175
|
+
var PROXY_NONCE_HEADER = "x-spfn-proxy-nonce";
|
|
176
|
+
var PROXY_KEY_ID_HEADER = "x-spfn-proxy-key-id";
|
|
177
|
+
var PROXY_CLIENT_IP_HEADER = "x-spfn-proxy-client-ip";
|
|
178
|
+
var DEFAULT_KEY_ID = "default";
|
|
179
|
+
function parseProxyKey(raw) {
|
|
180
|
+
const idx = raw.indexOf(":");
|
|
181
|
+
if (idx === -1) {
|
|
182
|
+
return { keyId: DEFAULT_KEY_ID, secret: raw };
|
|
183
|
+
}
|
|
184
|
+
return { keyId: raw.slice(0, idx) || DEFAULT_KEY_ID, secret: raw.slice(idx + 1) };
|
|
185
|
+
}
|
|
186
|
+
function parseProxyKeySet(raws) {
|
|
187
|
+
const keys = [];
|
|
188
|
+
const seen = /* @__PURE__ */ new Set();
|
|
189
|
+
for (const raw of raws) {
|
|
190
|
+
if (!raw) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
for (const part of raw.split(",")) {
|
|
194
|
+
const trimmed = part.trim();
|
|
195
|
+
if (!trimmed) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const key = parseProxyKey(trimmed);
|
|
199
|
+
if (!key.secret || seen.has(key.keyId)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
seen.add(key.keyId);
|
|
203
|
+
keys.push(key);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return keys;
|
|
207
|
+
}
|
|
208
|
+
function buildCanonicalString(parts) {
|
|
209
|
+
return [parts.method.toUpperCase(), parts.path, parts.query, parts.timestamp, parts.nonce, parts.bodyHash].join("\n");
|
|
210
|
+
}
|
|
211
|
+
function hashBody(body) {
|
|
212
|
+
if (!body || body.length === 0) {
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
return typeof body === "string" ? createHash("sha256").update(body, "utf8").digest("hex") : createHash("sha256").update(body).digest("hex");
|
|
216
|
+
}
|
|
217
|
+
function computeSignature(secret, parts) {
|
|
218
|
+
return createHmac("sha256", secret).update(buildCanonicalString(parts)).digest("hex");
|
|
219
|
+
}
|
|
220
|
+
function safeEqualHex(a, b) {
|
|
221
|
+
const bufA = Buffer.from(a, "hex");
|
|
222
|
+
const bufB = Buffer.from(b, "hex");
|
|
223
|
+
if (bufA.length === 0 || bufA.length !== bufB.length) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
return timingSafeEqual(bufA, bufB);
|
|
227
|
+
}
|
|
228
|
+
function verifyProxyRequest(input) {
|
|
229
|
+
const { signature, timestamp, nonce, keyId } = input;
|
|
230
|
+
if (!signature || !timestamp || !nonce || !keyId) {
|
|
231
|
+
return { valid: false, reason: "missing-headers" };
|
|
232
|
+
}
|
|
233
|
+
const ts = Number(timestamp);
|
|
234
|
+
if (!Number.isFinite(ts)) {
|
|
235
|
+
return { valid: false, reason: "bad-timestamp" };
|
|
236
|
+
}
|
|
237
|
+
const now = input.now ?? Date.now();
|
|
238
|
+
const windowMs = input.windowMs ?? 3e4;
|
|
239
|
+
if (Math.abs(now - ts) > windowMs) {
|
|
240
|
+
return { valid: false, reason: "stale-timestamp" };
|
|
241
|
+
}
|
|
242
|
+
const key = input.keys.find((k) => k.keyId === keyId);
|
|
243
|
+
if (!key) {
|
|
244
|
+
return { valid: false, reason: "unknown-key" };
|
|
245
|
+
}
|
|
246
|
+
const expected = computeSignature(key.secret, {
|
|
247
|
+
method: input.method,
|
|
248
|
+
path: input.path,
|
|
249
|
+
query: input.query ?? "",
|
|
250
|
+
timestamp,
|
|
251
|
+
nonce,
|
|
252
|
+
bodyHash: hashBody(input.body)
|
|
253
|
+
});
|
|
254
|
+
if (!safeEqualHex(signature, expected)) {
|
|
255
|
+
return { valid: false, reason: "signature-mismatch" };
|
|
256
|
+
}
|
|
257
|
+
return { valid: true, nonce, keyId };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/route/define-middleware.ts
|
|
261
|
+
function defineMiddleware(name, handlerOrFactory, options) {
|
|
262
|
+
const skips = options?.skips;
|
|
263
|
+
if (typeof handlerOrFactory === "function") {
|
|
264
|
+
const paramCount = handlerOrFactory.length;
|
|
265
|
+
if (paramCount === 2) {
|
|
266
|
+
return {
|
|
267
|
+
name,
|
|
268
|
+
handler: handlerOrFactory,
|
|
269
|
+
_name: name,
|
|
270
|
+
...skips
|
|
271
|
+
};
|
|
272
|
+
} else {
|
|
273
|
+
const factory = handlerOrFactory;
|
|
274
|
+
const wrapper = (...args) => factory(...args);
|
|
275
|
+
Object.defineProperty(wrapper, "name", {
|
|
276
|
+
value: name,
|
|
277
|
+
writable: false,
|
|
278
|
+
enumerable: false,
|
|
279
|
+
configurable: true
|
|
280
|
+
});
|
|
281
|
+
Object.defineProperty(wrapper, "_name", {
|
|
282
|
+
value: name,
|
|
283
|
+
writable: false,
|
|
284
|
+
enumerable: false,
|
|
285
|
+
configurable: true
|
|
286
|
+
});
|
|
287
|
+
return wrapper;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
name,
|
|
292
|
+
handler: handlerOrFactory,
|
|
293
|
+
_name: name,
|
|
294
|
+
...skips
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function defineMiddlewareFactory(name, factory) {
|
|
298
|
+
const wrapper = (...args) => factory(...args);
|
|
299
|
+
Object.defineProperty(wrapper, "name", {
|
|
300
|
+
value: name,
|
|
301
|
+
writable: false,
|
|
302
|
+
enumerable: false,
|
|
303
|
+
configurable: true
|
|
304
|
+
});
|
|
305
|
+
Object.defineProperty(wrapper, "_name", {
|
|
306
|
+
value: name,
|
|
307
|
+
writable: false,
|
|
308
|
+
enumerable: false,
|
|
309
|
+
configurable: true
|
|
310
|
+
});
|
|
311
|
+
return wrapper;
|
|
312
|
+
}
|
|
313
|
+
logger.child("@spfn/core:cache");
|
|
314
|
+
logger.child("@spfn/core:cache");
|
|
315
|
+
var CACHE_KEY = Symbol.for("@spfn/core:cache");
|
|
316
|
+
var state = globalThis[CACHE_KEY] ??= {
|
|
317
|
+
write: void 0,
|
|
318
|
+
read: void 0,
|
|
319
|
+
disabled: false
|
|
320
|
+
};
|
|
321
|
+
function getCache() {
|
|
322
|
+
return state.write;
|
|
323
|
+
}
|
|
324
|
+
function isCacheDisabled() {
|
|
325
|
+
return state.disabled;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/errors/error-registry.ts
|
|
329
|
+
var ErrorRegistry = class _ErrorRegistry {
|
|
330
|
+
errors = /* @__PURE__ */ new Map();
|
|
331
|
+
constructor(initialErrors) {
|
|
332
|
+
if (initialErrors) {
|
|
333
|
+
for (const input of initialErrors) {
|
|
334
|
+
if (input instanceof _ErrorRegistry) {
|
|
335
|
+
this.concat(input);
|
|
336
|
+
} else if (Array.isArray(input)) {
|
|
337
|
+
this.append(input);
|
|
338
|
+
} else {
|
|
339
|
+
this.append(input);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Append error class(es) to the registry
|
|
346
|
+
*
|
|
347
|
+
* @param input - Error constructor or array of constructors
|
|
348
|
+
* @returns This registry for chaining
|
|
349
|
+
*/
|
|
350
|
+
append(input) {
|
|
351
|
+
if (Array.isArray(input)) {
|
|
352
|
+
for (const ErrorClass of input) {
|
|
353
|
+
this.errors.set(ErrorClass.name, ErrorClass);
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
this.errors.set(input.name, input);
|
|
357
|
+
}
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Concatenate another ErrorRegistry into this one
|
|
362
|
+
*
|
|
363
|
+
* @param registry - Another ErrorRegistry to merge
|
|
364
|
+
* @returns This registry for chaining
|
|
365
|
+
*/
|
|
366
|
+
concat(registry) {
|
|
367
|
+
for (const [name, ErrorClass] of registry.errors) {
|
|
368
|
+
this.errors.set(name, ErrorClass);
|
|
369
|
+
}
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Check if error type is registered
|
|
374
|
+
*
|
|
375
|
+
* @param name - Error class name
|
|
376
|
+
*/
|
|
377
|
+
has(name) {
|
|
378
|
+
return this.errors.has(name);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Deserialize error from JSON data
|
|
382
|
+
*
|
|
383
|
+
* @param data - Serialized error data with __type field
|
|
384
|
+
* @returns Deserialized error instance
|
|
385
|
+
* @throws Error if error type is not registered
|
|
386
|
+
*/
|
|
387
|
+
deserialize(data) {
|
|
388
|
+
const ErrorClass = this.errors.get(data.__type);
|
|
389
|
+
if (!ErrorClass) {
|
|
390
|
+
throw new Error(`Unknown error type: ${data.__type}`);
|
|
391
|
+
}
|
|
392
|
+
const { __type: _type, error: _envelope, ...fields } = data;
|
|
393
|
+
return new ErrorClass(fields);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Try to deserialize error, return null if type unknown
|
|
397
|
+
*
|
|
398
|
+
* @param data - Serialized error data
|
|
399
|
+
* @returns Deserialized error or null
|
|
400
|
+
*/
|
|
401
|
+
tryDeserialize(data) {
|
|
402
|
+
if (!data.__type || !this.has(data.__type)) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
return this.deserialize(data);
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Get all registered error types
|
|
413
|
+
*/
|
|
414
|
+
getRegisteredTypes() {
|
|
415
|
+
return Array.from(this.errors.keys());
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
var RESERVED_RESPONSE_KEYS = /* @__PURE__ */ new Set(["__type", "message", "error"]);
|
|
419
|
+
var AUTHORING_ENVIRONMENTS = /* @__PURE__ */ new Set(["local", "development", "test"]);
|
|
420
|
+
function refuseReservedKey(className, key) {
|
|
421
|
+
const detail = `${className} declares the reserved field "${key}". Reserved response keys: ${[...RESERVED_RESPONSE_KEYS].join(", ")}. Rename the field.`;
|
|
422
|
+
if (AUTHORING_ENVIRONMENTS.has(process.env.NODE_ENV ?? "local")) {
|
|
423
|
+
throw new Error(detail);
|
|
424
|
+
}
|
|
425
|
+
logger.error(`[SerializableError] ${detail} The field is omitted from the response.`);
|
|
426
|
+
}
|
|
427
|
+
var SerializableError2 = class extends Error {
|
|
428
|
+
/**
|
|
429
|
+
* Serialize error to JSON-compatible object
|
|
430
|
+
*
|
|
431
|
+
* Automatically includes:
|
|
432
|
+
* - __type: Constructor name for deserialization
|
|
433
|
+
* - message: Error message
|
|
434
|
+
* - All public instance properties (except name, stack)
|
|
435
|
+
*/
|
|
436
|
+
toJSON() {
|
|
437
|
+
const json = {
|
|
438
|
+
__type: this.constructor.name,
|
|
439
|
+
message: this.message
|
|
440
|
+
};
|
|
441
|
+
for (const key of Object.keys(this)) {
|
|
442
|
+
if (key === "name" || key === "stack" || key === "statusCode") {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (RESERVED_RESPONSE_KEYS.has(key)) {
|
|
446
|
+
refuseReservedKey(this.constructor.name, key);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
json[key] = this[key];
|
|
450
|
+
}
|
|
451
|
+
return json;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// src/errors/http-errors.ts
|
|
456
|
+
var HttpError = class extends SerializableError2 {
|
|
457
|
+
statusCode;
|
|
458
|
+
details;
|
|
459
|
+
constructor(data) {
|
|
460
|
+
super(data.message);
|
|
461
|
+
this.name = "HttpError";
|
|
462
|
+
this.statusCode = data.statusCode;
|
|
463
|
+
if (data.details) {
|
|
464
|
+
this.details = data.details;
|
|
465
|
+
}
|
|
466
|
+
Error.captureStackTrace(this, this.constructor);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
var BadRequestError = class extends HttpError {
|
|
470
|
+
constructor(data = {}) {
|
|
471
|
+
super({
|
|
472
|
+
message: data.message || "Bad request",
|
|
473
|
+
statusCode: 400,
|
|
474
|
+
details: data.details
|
|
475
|
+
});
|
|
476
|
+
this.name = "BadRequestError";
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
var ValidationError = class extends HttpError {
|
|
480
|
+
fields;
|
|
481
|
+
constructor(data) {
|
|
482
|
+
super({
|
|
483
|
+
message: data.message,
|
|
484
|
+
statusCode: 400,
|
|
485
|
+
details: data.details
|
|
486
|
+
});
|
|
487
|
+
this.name = "ValidationError";
|
|
488
|
+
if (data.fields) {
|
|
489
|
+
this.fields = data.fields;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
var UnauthorizedError = class extends HttpError {
|
|
494
|
+
constructor(data = {}) {
|
|
495
|
+
super({
|
|
496
|
+
message: data.message || "Authentication required",
|
|
497
|
+
statusCode: 401,
|
|
498
|
+
details: data.details
|
|
499
|
+
});
|
|
500
|
+
this.name = "UnauthorizedError";
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
var ForbiddenError = class extends HttpError {
|
|
504
|
+
constructor(data = {}) {
|
|
505
|
+
super({
|
|
506
|
+
message: data.message || "Access forbidden",
|
|
507
|
+
statusCode: 403,
|
|
508
|
+
details: data.details
|
|
509
|
+
});
|
|
510
|
+
this.name = "ForbiddenError";
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
var NotFoundError = class extends HttpError {
|
|
514
|
+
resource;
|
|
515
|
+
constructor(data = {}) {
|
|
516
|
+
super({
|
|
517
|
+
message: data.message || "Resource not found",
|
|
518
|
+
statusCode: 404,
|
|
519
|
+
details: data.details
|
|
520
|
+
});
|
|
521
|
+
this.name = "NotFoundError";
|
|
522
|
+
if (data.resource) {
|
|
523
|
+
this.resource = data.resource;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
var ConflictError = class extends HttpError {
|
|
528
|
+
constructor(data = {}) {
|
|
529
|
+
super({
|
|
530
|
+
message: data.message || "Resource conflict",
|
|
531
|
+
statusCode: 409,
|
|
532
|
+
details: data.details
|
|
533
|
+
});
|
|
534
|
+
this.name = "ConflictError";
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
var GoneError = class extends HttpError {
|
|
538
|
+
resource;
|
|
539
|
+
constructor(data = {}) {
|
|
540
|
+
super({
|
|
541
|
+
message: data.message || "Resource permanently deleted",
|
|
542
|
+
statusCode: 410,
|
|
543
|
+
details: data.details
|
|
544
|
+
});
|
|
545
|
+
this.name = "GoneError";
|
|
546
|
+
if (data.resource) {
|
|
547
|
+
this.resource = data.resource;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
var TooManyRequestsError = class extends HttpError {
|
|
552
|
+
retryAfter;
|
|
553
|
+
constructor(data = {}) {
|
|
554
|
+
super({
|
|
555
|
+
message: data.message || "Too many requests",
|
|
556
|
+
statusCode: 429,
|
|
557
|
+
details: data.details
|
|
558
|
+
});
|
|
559
|
+
this.name = "TooManyRequestsError";
|
|
560
|
+
if (data.retryAfter) {
|
|
561
|
+
this.retryAfter = data.retryAfter;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
var InternalServerError = class extends HttpError {
|
|
566
|
+
constructor(data = {}) {
|
|
567
|
+
super({
|
|
568
|
+
message: data.message || "Internal server error",
|
|
569
|
+
statusCode: 500,
|
|
570
|
+
details: data.details
|
|
571
|
+
});
|
|
572
|
+
this.name = "InternalServerError";
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
var UnsupportedMediaTypeError = class extends HttpError {
|
|
576
|
+
mediaType;
|
|
577
|
+
supportedTypes;
|
|
578
|
+
constructor(data = {}) {
|
|
579
|
+
super({
|
|
580
|
+
message: data.message || "Unsupported media type",
|
|
581
|
+
statusCode: 415,
|
|
582
|
+
details: data.details
|
|
583
|
+
});
|
|
584
|
+
this.name = "UnsupportedMediaTypeError";
|
|
585
|
+
if (data.mediaType) {
|
|
586
|
+
this.mediaType = data.mediaType;
|
|
587
|
+
}
|
|
588
|
+
if (data.supportedTypes) {
|
|
589
|
+
this.supportedTypes = data.supportedTypes;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
var UnprocessableEntityError = class extends HttpError {
|
|
594
|
+
constructor(data = {}) {
|
|
595
|
+
super({
|
|
596
|
+
message: data.message || "Unprocessable entity",
|
|
597
|
+
statusCode: 422,
|
|
598
|
+
details: data.details
|
|
599
|
+
});
|
|
600
|
+
this.name = "UnprocessableEntityError";
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
var ServiceUnavailableError = class extends HttpError {
|
|
604
|
+
retryAfter;
|
|
605
|
+
constructor(data = {}) {
|
|
606
|
+
super({
|
|
607
|
+
message: data.message || "Service unavailable",
|
|
608
|
+
statusCode: 503,
|
|
609
|
+
details: data.details
|
|
610
|
+
});
|
|
611
|
+
this.name = "ServiceUnavailableError";
|
|
612
|
+
if (data.retryAfter) {
|
|
613
|
+
this.retryAfter = data.retryAfter;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// src/errors/database-errors.ts
|
|
619
|
+
var DatabaseError = class extends SerializableError2 {
|
|
620
|
+
statusCode;
|
|
621
|
+
details;
|
|
622
|
+
constructor(data) {
|
|
623
|
+
super(data.message);
|
|
624
|
+
this.name = "DatabaseError";
|
|
625
|
+
this.statusCode = data.statusCode ?? 500;
|
|
626
|
+
this.details = data.details;
|
|
627
|
+
Error.captureStackTrace(this, this.constructor);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Whether this error's message/details are derived from the raw database
|
|
631
|
+
* driver (SQL text, table/column/constraint names, parameter values) and
|
|
632
|
+
* therefore must NOT be exposed to clients in production. Defined as a
|
|
633
|
+
* prototype getter so it is never serialized into the response by toJSON().
|
|
634
|
+
* Subclasses with a safe, constructed message override this to `false`.
|
|
635
|
+
*/
|
|
636
|
+
get internal() {
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
var ConnectionError = class extends DatabaseError {
|
|
641
|
+
constructor(data) {
|
|
642
|
+
super({ message: data.message, statusCode: 503, details: data.details });
|
|
643
|
+
this.name = "ConnectionError";
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
var QueryError = class extends DatabaseError {
|
|
647
|
+
constructor(data) {
|
|
648
|
+
super({
|
|
649
|
+
message: data.message,
|
|
650
|
+
statusCode: data.statusCode ?? 500,
|
|
651
|
+
details: data.details
|
|
652
|
+
});
|
|
653
|
+
this.name = "QueryError";
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
var EntityNotFoundError = class extends QueryError {
|
|
657
|
+
resource;
|
|
658
|
+
id;
|
|
659
|
+
constructor(data) {
|
|
660
|
+
super({
|
|
661
|
+
message: `${data.resource} with id ${data.id} not found`,
|
|
662
|
+
statusCode: 404,
|
|
663
|
+
details: { resource: data.resource, id: data.id }
|
|
664
|
+
});
|
|
665
|
+
this.name = "EntityNotFoundError";
|
|
666
|
+
this.resource = data.resource;
|
|
667
|
+
this.id = data.id;
|
|
668
|
+
}
|
|
669
|
+
// Message/details are constructed from the resource + id, not driver text
|
|
670
|
+
get internal() {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
var ConstraintViolationError = class extends QueryError {
|
|
675
|
+
constructor(data) {
|
|
676
|
+
super({ message: data.message, statusCode: 400, details: data.details });
|
|
677
|
+
this.name = "ConstraintViolationError";
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
var TransactionError = class extends DatabaseError {
|
|
681
|
+
constructor(data) {
|
|
682
|
+
super({
|
|
683
|
+
message: data.message,
|
|
684
|
+
statusCode: data.statusCode ?? 500,
|
|
685
|
+
details: data.details
|
|
686
|
+
});
|
|
687
|
+
this.name = "TransactionError";
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
var DeadlockError = class extends TransactionError {
|
|
691
|
+
constructor(data) {
|
|
692
|
+
super({ message: data.message, statusCode: 409, details: data.details });
|
|
693
|
+
this.name = "DeadlockError";
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
var DuplicateEntryError = class extends QueryError {
|
|
697
|
+
field;
|
|
698
|
+
value;
|
|
699
|
+
constructor(data) {
|
|
700
|
+
super({
|
|
701
|
+
message: `${data.field} already exists`,
|
|
702
|
+
statusCode: 409,
|
|
703
|
+
details: { field: data.field }
|
|
704
|
+
});
|
|
705
|
+
this.name = "DuplicateEntryError";
|
|
706
|
+
this.field = data.field;
|
|
707
|
+
this.value = data.value;
|
|
708
|
+
}
|
|
709
|
+
// Field-only message/details — safe to surface to the client (not internal)
|
|
710
|
+
get internal() {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
// src/errors/index.ts
|
|
716
|
+
var errorRegistry = new ErrorRegistry();
|
|
717
|
+
errorRegistry.append([
|
|
718
|
+
HttpError,
|
|
719
|
+
BadRequestError,
|
|
720
|
+
ValidationError,
|
|
721
|
+
UnauthorizedError,
|
|
722
|
+
ForbiddenError,
|
|
723
|
+
NotFoundError,
|
|
724
|
+
ConflictError,
|
|
725
|
+
GoneError,
|
|
726
|
+
TooManyRequestsError,
|
|
727
|
+
UnsupportedMediaTypeError,
|
|
728
|
+
UnprocessableEntityError,
|
|
729
|
+
InternalServerError,
|
|
730
|
+
ServiceUnavailableError
|
|
731
|
+
]);
|
|
732
|
+
errorRegistry.append([
|
|
733
|
+
DatabaseError,
|
|
734
|
+
ConnectionError,
|
|
735
|
+
QueryError,
|
|
736
|
+
EntityNotFoundError,
|
|
737
|
+
ConstraintViolationError,
|
|
738
|
+
TransactionError,
|
|
739
|
+
DeadlockError,
|
|
740
|
+
DuplicateEntryError
|
|
741
|
+
]);
|
|
742
|
+
|
|
743
|
+
// src/middleware/rate-limit-store.ts
|
|
744
|
+
var FIXED_WINDOW_LUA = `
|
|
745
|
+
local count = redis.call('INCR', KEYS[1])
|
|
746
|
+
if count == 1 then
|
|
747
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
748
|
+
end
|
|
749
|
+
return { count, redis.call('PTTL', KEYS[1]) }
|
|
750
|
+
`;
|
|
751
|
+
var CacheRateLimitStore = class {
|
|
752
|
+
constructor(cache) {
|
|
753
|
+
this.cache = cache;
|
|
754
|
+
}
|
|
755
|
+
async hit(key, windowMs) {
|
|
756
|
+
const [count, pttl] = await this.cache.eval(
|
|
757
|
+
FIXED_WINDOW_LUA,
|
|
758
|
+
1,
|
|
759
|
+
key,
|
|
760
|
+
String(windowMs)
|
|
761
|
+
);
|
|
762
|
+
return { count, pttl };
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
var MemoryRateLimitStore = class {
|
|
766
|
+
constructor(maxKeys = 1e4) {
|
|
767
|
+
this.maxKeys = maxKeys;
|
|
768
|
+
}
|
|
769
|
+
windows = /* @__PURE__ */ new Map();
|
|
770
|
+
/** How many live windows were dropped to stay under `maxKeys`. */
|
|
771
|
+
evicted = 0;
|
|
772
|
+
async hit(key, windowMs) {
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
const existing = this.windows.get(key);
|
|
775
|
+
if (existing !== void 0 && existing.expiresAt > now) {
|
|
776
|
+
existing.count += 1;
|
|
777
|
+
return { count: existing.count, pttl: existing.expiresAt - now };
|
|
778
|
+
}
|
|
779
|
+
if (this.windows.size >= this.maxKeys) {
|
|
780
|
+
this.makeRoom(now);
|
|
781
|
+
}
|
|
782
|
+
this.windows.set(key, { count: 1, expiresAt: now + windowMs });
|
|
783
|
+
return { count: 1, pttl: windowMs };
|
|
784
|
+
}
|
|
785
|
+
/** Drops every window that has already reset. */
|
|
786
|
+
prune(nowMillis) {
|
|
787
|
+
for (const [key, window] of this.windows) {
|
|
788
|
+
if (window.expiresAt <= nowMillis) {
|
|
789
|
+
this.windows.delete(key);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Frees a slot for a new key.
|
|
795
|
+
*
|
|
796
|
+
* Expired windows go first. If every window is still live the oldest one is
|
|
797
|
+
* dropped, because a hard bound is the only thing standing between a
|
|
798
|
+
* per-IP limiter and unbounded memory. A dropped window loses its count,
|
|
799
|
+
* so a caller who can mint `maxKeys` distinct identities can push another
|
|
800
|
+
* one out — which is why `evictionCount` is worth watching: it says this
|
|
801
|
+
* process is past what an in-memory limiter should be asked to hold, and
|
|
802
|
+
* the deployment wants a real cache.
|
|
803
|
+
*/
|
|
804
|
+
makeRoom(nowMillis) {
|
|
805
|
+
this.prune(nowMillis);
|
|
806
|
+
if (this.windows.size < this.maxKeys) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const oldest = this.windows.keys().next();
|
|
810
|
+
if (!oldest.done) {
|
|
811
|
+
this.windows.delete(oldest.value);
|
|
812
|
+
this.evicted += 1;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
get size() {
|
|
816
|
+
return this.windows.size;
|
|
817
|
+
}
|
|
818
|
+
/** Live windows dropped for capacity — nonzero means the bound is binding. */
|
|
819
|
+
get evictionCount() {
|
|
820
|
+
return this.evicted;
|
|
821
|
+
}
|
|
822
|
+
clear() {
|
|
823
|
+
this.windows.clear();
|
|
824
|
+
this.evicted = 0;
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
// src/middleware/rate-limit.ts
|
|
829
|
+
var rateLimitLogger = logger.child("@spfn/core:rate-limit");
|
|
830
|
+
var memoryStore = new MemoryRateLimitStore();
|
|
831
|
+
function resetMemoryRateLimitStore() {
|
|
832
|
+
memoryStore.clear();
|
|
833
|
+
}
|
|
834
|
+
function getClientIp(c) {
|
|
835
|
+
const clientType = c.get("clientType");
|
|
836
|
+
if (clientType && clientType !== "untrusted") {
|
|
837
|
+
const forwarded = c.req.header(PROXY_CLIENT_IP_HEADER);
|
|
838
|
+
if (forwarded) {
|
|
839
|
+
return forwarded;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const forwardedFor = c.req.header("x-forwarded-for");
|
|
843
|
+
return forwardedFor?.split(",")[0]?.trim() || c.req.header("x-real-ip") || socketRemoteAddress(c) || "unknown";
|
|
844
|
+
}
|
|
845
|
+
function socketRemoteAddress(c) {
|
|
846
|
+
const env4 = c.env;
|
|
847
|
+
const bindings = env4?.server ?? env4;
|
|
848
|
+
return bindings?.incoming?.socket?.remoteAddress;
|
|
849
|
+
}
|
|
850
|
+
function selectStore(failClosed, message, path) {
|
|
851
|
+
const cache = getCache();
|
|
852
|
+
if (cache && !isCacheDisabled()) {
|
|
853
|
+
return new CacheRateLimitStore(cache);
|
|
854
|
+
}
|
|
855
|
+
if (failClosed) {
|
|
856
|
+
throw new TooManyRequestsError({ message: message || "Rate limiter unavailable" });
|
|
857
|
+
}
|
|
858
|
+
rateLimitLogger.debug("No cache \u2014 counting rate limits in this process", { path });
|
|
859
|
+
return memoryStore;
|
|
860
|
+
}
|
|
861
|
+
async function hitWithFallback(store, key, windowMs, failClosed, message, path) {
|
|
862
|
+
try {
|
|
863
|
+
return await store.hit(key, windowMs);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (failClosed) {
|
|
866
|
+
throw new TooManyRequestsError({ message: message || "Rate limiter unavailable" });
|
|
867
|
+
}
|
|
868
|
+
rateLimitLogger.warn("Cache failed \u2014 counting this rate limit in the process instead", {
|
|
869
|
+
path,
|
|
870
|
+
error: error instanceof Error ? error.message : String(error)
|
|
871
|
+
});
|
|
872
|
+
return memoryStore.hit(key, windowMs);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
var rateLimit = defineMiddlewareFactory(
|
|
876
|
+
"rateLimit",
|
|
877
|
+
(options) => {
|
|
878
|
+
const { limit, windowMs, scope, by, failClosed = false, message } = options;
|
|
879
|
+
return async (c, next) => {
|
|
880
|
+
const store = selectStore(failClosed, message, c.req.path);
|
|
881
|
+
const dimensions = (by ? await by(c) : [getClientIp(c)]).filter((d) => Boolean(d));
|
|
882
|
+
const ns = scope || `${c.req.method} ${c.req.routePath || c.req.path}`;
|
|
883
|
+
for (const dimension of dimensions) {
|
|
884
|
+
const key = typeof dimension === "string" ? dimension : dimension.key;
|
|
885
|
+
if (!key) {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const dimLimit = typeof dimension === "string" ? limit : dimension.limit ?? limit;
|
|
889
|
+
const { count, pttl } = await hitWithFallback(
|
|
890
|
+
store,
|
|
891
|
+
`ratelimit:${ns}:${key}`,
|
|
892
|
+
windowMs,
|
|
893
|
+
failClosed,
|
|
894
|
+
message,
|
|
895
|
+
c.req.path
|
|
896
|
+
);
|
|
897
|
+
if (count > dimLimit) {
|
|
898
|
+
const retryAfter = Math.max(1, Math.ceil((pttl > 0 ? pttl : windowMs) / 1e3));
|
|
899
|
+
c.header("Retry-After", String(retryAfter));
|
|
900
|
+
throw new TooManyRequestsError({
|
|
901
|
+
message: message || "Too many requests, please try again later",
|
|
902
|
+
retryAfter
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return next();
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
);
|
|
910
|
+
var policyRegistry = /* @__PURE__ */ new Map();
|
|
911
|
+
var policyFailClosedDefault = false;
|
|
912
|
+
function setRateLimitFailClosedDefault(failClosed) {
|
|
913
|
+
policyFailClosedDefault = failClosed;
|
|
914
|
+
}
|
|
915
|
+
function setRateLimitPolicies(policies) {
|
|
916
|
+
policyRegistry.clear();
|
|
917
|
+
if (!policies) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
for (const [name, options] of Object.entries(policies)) {
|
|
921
|
+
policyRegistry.set(name, options);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function getRateLimitPolicy(name) {
|
|
925
|
+
return policyRegistry.get(name);
|
|
926
|
+
}
|
|
927
|
+
function rateLimitPolicy(name, fallback) {
|
|
928
|
+
let resolved;
|
|
929
|
+
const handler = (c, next) => {
|
|
930
|
+
if (!resolved) {
|
|
931
|
+
const configured = getRateLimitPolicy(name);
|
|
932
|
+
const merged = configured ? { ...fallback, ...configured } : { ...fallback };
|
|
933
|
+
if (merged.scope === void 0) {
|
|
934
|
+
merged.scope = name;
|
|
935
|
+
}
|
|
936
|
+
if (merged.failClosed === void 0) {
|
|
937
|
+
merged.failClosed = policyFailClosedDefault;
|
|
938
|
+
}
|
|
939
|
+
resolved = rateLimit(merged);
|
|
940
|
+
}
|
|
941
|
+
return resolved(c, next);
|
|
942
|
+
};
|
|
943
|
+
return defineMiddleware(`rateLimit:${name}`, handler);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/middleware/request-logger.ts
|
|
63
947
|
var DEFAULT_CONFIG = {
|
|
64
|
-
excludePaths: ["/health", "/ping", "/favicon.ico"],
|
|
948
|
+
excludePaths: ["/_core/health", "/health", "/ping", "/favicon.ico"],
|
|
65
949
|
sensitiveFields: ["password", "token", "apiKey", "secret", "authorization"],
|
|
66
950
|
slowRequestThreshold: 1e3
|
|
67
951
|
};
|
|
@@ -101,8 +985,7 @@ function RequestLogger(options) {
|
|
|
101
985
|
c.set("requestId", requestId);
|
|
102
986
|
const method = c.req.method;
|
|
103
987
|
const userAgent = c.req.header("user-agent");
|
|
104
|
-
const
|
|
105
|
-
const ip = forwardedFor?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
|
|
988
|
+
const ip = getClientIp(c);
|
|
106
989
|
const startTime = Date.now();
|
|
107
990
|
apiLogger.info("Request received", {
|
|
108
991
|
requestId,
|
|
@@ -153,7 +1036,190 @@ function RequestLogger(options) {
|
|
|
153
1036
|
}
|
|
154
1037
|
};
|
|
155
1038
|
}
|
|
1039
|
+
var guardLogger = logger.child("@spfn/core:proxy-guard");
|
|
1040
|
+
var BODY_OVERSIZE = Symbol("proxy-guard:body-oversize");
|
|
1041
|
+
var BODY_READ_ERROR = Symbol("proxy-guard:body-read-error");
|
|
1042
|
+
async function readRawBody(c, maxBytes) {
|
|
1043
|
+
const method = c.req.method;
|
|
1044
|
+
if (method === "GET" || method === "HEAD") {
|
|
1045
|
+
return void 0;
|
|
1046
|
+
}
|
|
1047
|
+
const contentType = c.req.header("content-type") || "";
|
|
1048
|
+
if (contentType.includes("multipart/form-data")) {
|
|
1049
|
+
return void 0;
|
|
1050
|
+
}
|
|
1051
|
+
const stream = c.req.raw.clone().body;
|
|
1052
|
+
if (!stream) {
|
|
1053
|
+
return void 0;
|
|
1054
|
+
}
|
|
1055
|
+
const reader = stream.getReader();
|
|
1056
|
+
const chunks = [];
|
|
1057
|
+
let total = 0;
|
|
1058
|
+
try {
|
|
1059
|
+
for (; ; ) {
|
|
1060
|
+
const { done, value } = await reader.read();
|
|
1061
|
+
if (done) {
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
total += value.byteLength;
|
|
1065
|
+
if (maxBytes !== void 0 && total > maxBytes) {
|
|
1066
|
+
void reader.cancel().catch(() => void 0);
|
|
1067
|
+
return BODY_OVERSIZE;
|
|
1068
|
+
}
|
|
1069
|
+
chunks.push(value);
|
|
1070
|
+
}
|
|
1071
|
+
} catch {
|
|
1072
|
+
guardLogger.debug("Request body read failed (client abort?)");
|
|
1073
|
+
return BODY_READ_ERROR;
|
|
1074
|
+
}
|
|
1075
|
+
return Buffer.concat(chunks);
|
|
1076
|
+
}
|
|
1077
|
+
function isOriginAllowed(c, allowedOrigins) {
|
|
1078
|
+
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
const origin = c.req.header("origin");
|
|
1082
|
+
if (!origin) {
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
return allowedOrigins.includes(origin);
|
|
1086
|
+
}
|
|
1087
|
+
function createProxyGuard(config = {}) {
|
|
1088
|
+
const mode = config.mode ?? "off";
|
|
1089
|
+
const windowMs = config.windowMs ?? 3e4;
|
|
1090
|
+
const allowedOrigins = config.allowedOrigins;
|
|
1091
|
+
const nonceStore = config.nonceStore;
|
|
1092
|
+
const nonceFailClosed = config.nonceFailClosed ?? false;
|
|
1093
|
+
const skipPaths = new Set(config.skipPaths ?? []);
|
|
1094
|
+
const maxBodyBytes = config.maxBodyBytes;
|
|
1095
|
+
const activeRaw = config.secret ?? env.SPFN_PROXY_SECRET;
|
|
1096
|
+
const previousRaw = config.previousSecrets ?? env.SPFN_PROXY_SECRET_PREVIOUS;
|
|
1097
|
+
const keys = parseProxyKeySet([activeRaw, previousRaw]);
|
|
1098
|
+
if (mode === "strict" && keys.length === 0) {
|
|
1099
|
+
throw new Error(
|
|
1100
|
+
'[proxy-guard] mode "strict" requires a proxy key but none is configured (SPFN_PROXY_SECRET is empty/unset). Refusing to start with the guard open \u2014 set the secret, or use mode "tag" / "off".'
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
if (activeRaw && previousRaw) {
|
|
1104
|
+
const activeId = parseProxyKey(activeRaw).keyId;
|
|
1105
|
+
const collides = previousRaw.split(",").some((p) => p.trim() && parseProxyKey(p.trim()).keyId === activeId);
|
|
1106
|
+
if (collides) {
|
|
1107
|
+
guardLogger.warn(
|
|
1108
|
+
'Previous proxy key shares keyId with the active key (likely bare secrets without a "keyId:" prefix) \u2014 grace key ignored, rotation will drop in-flight requests. Use "<keyId>:<secret>" on both keys.',
|
|
1109
|
+
{ keyId: activeId }
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return async (c, next) => {
|
|
1114
|
+
if (mode === "off" || skipPaths.has(c.req.path)) {
|
|
1115
|
+
return next();
|
|
1116
|
+
}
|
|
1117
|
+
if (c.req.method === "OPTIONS" && c.req.header("access-control-request-method")) {
|
|
1118
|
+
c.set("clientType", "untrusted");
|
|
1119
|
+
return next();
|
|
1120
|
+
}
|
|
1121
|
+
if (!isOriginAllowed(c, allowedOrigins)) {
|
|
1122
|
+
return reject(c, mode, next, "origin-not-allowed");
|
|
1123
|
+
}
|
|
1124
|
+
if (keys.length === 0) {
|
|
1125
|
+
c.set("clientType", "untrusted");
|
|
1126
|
+
return next();
|
|
1127
|
+
}
|
|
1128
|
+
const signature = c.req.header(PROXY_SIGNATURE_HEADER);
|
|
1129
|
+
const timestamp = c.req.header(PROXY_TIMESTAMP_HEADER);
|
|
1130
|
+
const nonce = c.req.header(PROXY_NONCE_HEADER);
|
|
1131
|
+
const keyId = c.req.header(PROXY_KEY_ID_HEADER);
|
|
1132
|
+
if (!signature || !timestamp || !nonce || !keyId) {
|
|
1133
|
+
return reject(c, mode, next, "missing-headers");
|
|
1134
|
+
}
|
|
1135
|
+
const body = await readRawBody(c, maxBodyBytes);
|
|
1136
|
+
if (body === BODY_OVERSIZE) {
|
|
1137
|
+
if (mode === "strict") {
|
|
1138
|
+
return c.json({ error: "Payload Too Large" }, 413);
|
|
1139
|
+
}
|
|
1140
|
+
c.set("clientType", "untrusted");
|
|
1141
|
+
return next();
|
|
1142
|
+
}
|
|
1143
|
+
if (body === BODY_READ_ERROR) {
|
|
1144
|
+
return reject(c, mode, next, "body-read-error");
|
|
1145
|
+
}
|
|
1146
|
+
const url = new URL(c.req.url);
|
|
1147
|
+
const result = verifyProxyRequest({
|
|
1148
|
+
keys,
|
|
1149
|
+
method: c.req.method,
|
|
1150
|
+
path: url.pathname,
|
|
1151
|
+
query: url.search,
|
|
1152
|
+
body,
|
|
1153
|
+
signature,
|
|
1154
|
+
timestamp,
|
|
1155
|
+
nonce,
|
|
1156
|
+
keyId,
|
|
1157
|
+
windowMs
|
|
1158
|
+
});
|
|
1159
|
+
if (!result.valid) {
|
|
1160
|
+
return reject(c, mode, next, result.reason);
|
|
1161
|
+
}
|
|
1162
|
+
if (nonceStore && result.nonce) {
|
|
1163
|
+
try {
|
|
1164
|
+
const fresh = await nonceStore.checkAndSet(result.nonce, windowMs * 2);
|
|
1165
|
+
if (!fresh) {
|
|
1166
|
+
return reject(c, mode, next, "nonce-replay");
|
|
1167
|
+
}
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
if (nonceFailClosed) {
|
|
1170
|
+
guardLogger.warn("Nonce store unavailable \u2014 rejecting (fail-closed)", {
|
|
1171
|
+
error: err.message
|
|
1172
|
+
});
|
|
1173
|
+
return reject(c, mode, next, "nonce-store-unavailable");
|
|
1174
|
+
}
|
|
1175
|
+
guardLogger.warn("Nonce store unavailable \u2014 falling back to timestamp window", {
|
|
1176
|
+
error: err.message
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
c.set("clientType", "web");
|
|
1181
|
+
return next();
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
function createCacheNonceStore(cache, prefix = "spfn:proxy-nonce:") {
|
|
1185
|
+
return {
|
|
1186
|
+
async checkAndSet(nonce, ttlMs) {
|
|
1187
|
+
const res = await cache.set(`${prefix}${nonce}`, "1", "PX", Math.ceil(ttlMs), "NX");
|
|
1188
|
+
return res === "OK";
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
function createInMemoryNonceStore() {
|
|
1193
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1194
|
+
let lastSweep = 0;
|
|
1195
|
+
return {
|
|
1196
|
+
async checkAndSet(nonce, ttlMs) {
|
|
1197
|
+
const now = Date.now();
|
|
1198
|
+
if (now - lastSweep > 6e4) {
|
|
1199
|
+
for (const [n, exp] of seen) {
|
|
1200
|
+
if (exp <= now) seen.delete(n);
|
|
1201
|
+
}
|
|
1202
|
+
lastSweep = now;
|
|
1203
|
+
}
|
|
1204
|
+
const existing = seen.get(nonce);
|
|
1205
|
+
if (existing !== void 0 && existing > now) {
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
seen.set(nonce, now + ttlMs);
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function reject(c, mode, next, reason) {
|
|
1214
|
+
if (mode === "strict") {
|
|
1215
|
+
guardLogger.warn("Rejected unverified request", { reason, path: c.req.path, method: c.req.method });
|
|
1216
|
+
return c.json({ error: "Forbidden", message: "Request origin could not be verified" }, 403);
|
|
1217
|
+
}
|
|
1218
|
+
guardLogger.debug("Unverified request (would reject in strict mode)", { reason, path: c.req.path });
|
|
1219
|
+
c.set("clientType", "untrusted");
|
|
1220
|
+
return next();
|
|
1221
|
+
}
|
|
156
1222
|
|
|
157
|
-
export { ErrorHandler, RequestLogger, maskSensitiveData };
|
|
1223
|
+
export { CacheRateLimitStore, ErrorHandler, MemoryRateLimitStore, RequestLogger, createCacheNonceStore, createInMemoryNonceStore, createProxyGuard, getClientIp, getRateLimitPolicy, maskSensitiveData, rateLimit, rateLimitPolicy, resetMemoryRateLimitStore, setRateLimitFailClosedDefault, setRateLimitPolicies };
|
|
158
1224
|
//# sourceMappingURL=index.js.map
|
|
159
1225
|
//# sourceMappingURL=index.js.map
|