@luckystack/api 0.1.9 → 0.2.1
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 +14 -14
- package/CLAUDE.md +113 -106
- package/LICENSE +21 -21
- package/README.md +106 -106
- package/dist/index.d.ts +4 -2
- package/dist/index.js +779 -503
- package/dist/index.js.map +1 -1
- package/docs/api-request-lifecycle.md +2 -2
- package/docs/auth-flow.md +232 -232
- package/docs/error-handling.md +3 -3
- package/docs/generated-types.md +1 -1
- package/docs/rate-limiting.md +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,46 +1,312 @@
|
|
|
1
1
|
// src/handleApiRequest.ts
|
|
2
|
-
import { getSession, logout } from "@luckystack/login";
|
|
3
|
-
import { getProjectConfig } from "@luckystack/core";
|
|
4
|
-
import { getRuntimeApiMaps } from "@luckystack/core";
|
|
5
2
|
import {
|
|
3
|
+
readSession,
|
|
4
|
+
performLogout,
|
|
5
|
+
runWithErrorTrackerIdentityScope,
|
|
6
|
+
setCurrentErrorTrackerIdentity,
|
|
7
|
+
getProjectConfig as getProjectConfig4,
|
|
8
|
+
getRuntimeApiMaps,
|
|
6
9
|
validateRequest,
|
|
7
|
-
checkRateLimit,
|
|
10
|
+
checkRateLimit as checkRateLimit2,
|
|
8
11
|
tryCatch,
|
|
9
12
|
parseTransportRouteName,
|
|
10
13
|
buildApiResponseEventName,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
dispatchHook as dispatchHook3,
|
|
15
|
+
getLogger as getLogger7,
|
|
16
|
+
resolveClientIp,
|
|
17
|
+
extractLanguageFromHeader,
|
|
18
|
+
normalizeErrorResponse as normalizeErrorResponse2,
|
|
19
|
+
applyErrorFormatter
|
|
15
20
|
} from "@luckystack/core";
|
|
21
|
+
|
|
22
|
+
// src/_shared/logFlags.ts
|
|
23
|
+
import { getProjectConfig } from "@luckystack/core";
|
|
24
|
+
var shouldLogDev = () => getProjectConfig().logging.devLogs;
|
|
25
|
+
var shouldLogStream = () => getProjectConfig().logging.stream;
|
|
26
|
+
|
|
27
|
+
// src/_shared/responseEnvelope.ts
|
|
28
|
+
import { defaultHttpStatusForResponse, normalizeErrorResponse, getLogger } from "@luckystack/core";
|
|
29
|
+
var normalizeApiResponse = ({
|
|
30
|
+
resolvedName,
|
|
31
|
+
error,
|
|
32
|
+
result,
|
|
33
|
+
preferredLocale,
|
|
34
|
+
user
|
|
35
|
+
}) => {
|
|
36
|
+
if (error) {
|
|
37
|
+
if (shouldLogDev()) {
|
|
38
|
+
getLogger().error(`api: error in ${resolvedName}`, error, { route: resolvedName });
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...normalizeErrorResponse({
|
|
42
|
+
response: { status: "error", errorCode: "api.internalServerError" },
|
|
43
|
+
preferredLocale,
|
|
44
|
+
userLanguage: user?.language,
|
|
45
|
+
fallbackHttpStatus: 500
|
|
46
|
+
})
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (result === void 0 || result === null) {
|
|
50
|
+
if (shouldLogDev()) {
|
|
51
|
+
getLogger().warn(`api: ${resolvedName} returned nothing`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
...normalizeErrorResponse({
|
|
55
|
+
response: { status: "error", errorCode: "api.emptyResponse" },
|
|
56
|
+
preferredLocale,
|
|
57
|
+
userLanguage: user?.language,
|
|
58
|
+
fallbackHttpStatus: 500
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (shouldLogDev()) {
|
|
63
|
+
getLogger().debug(`api: ${resolvedName} completed`);
|
|
64
|
+
}
|
|
65
|
+
if (result.status !== "success" && result.status !== "error") {
|
|
66
|
+
return {
|
|
67
|
+
...normalizeErrorResponse({
|
|
68
|
+
response: { status: "error", errorCode: "api.invalidResponseStatus" },
|
|
69
|
+
preferredLocale,
|
|
70
|
+
userLanguage: user?.language,
|
|
71
|
+
fallbackHttpStatus: 500
|
|
72
|
+
})
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (result.status === "error") {
|
|
76
|
+
return {
|
|
77
|
+
...normalizeErrorResponse({
|
|
78
|
+
response: result,
|
|
79
|
+
preferredLocale,
|
|
80
|
+
userLanguage: user?.language,
|
|
81
|
+
fallbackHttpStatus: defaultHttpStatusForResponse({
|
|
82
|
+
status: "error",
|
|
83
|
+
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...result,
|
|
90
|
+
status: "success",
|
|
91
|
+
httpStatus: defaultHttpStatusForResponse({
|
|
92
|
+
status: "success",
|
|
93
|
+
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
94
|
+
})
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/_shared/requestLifecycle.ts
|
|
16
99
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
normalizeErrorResponse,
|
|
20
|
-
applyErrorFormatter,
|
|
100
|
+
buildApiStreamEventName,
|
|
101
|
+
getLogger as getLogger3,
|
|
21
102
|
registerApiAbortController,
|
|
22
103
|
unregisterApiAbortController,
|
|
23
104
|
socketEventNames
|
|
24
105
|
} from "@luckystack/core";
|
|
25
|
-
|
|
26
|
-
|
|
106
|
+
|
|
107
|
+
// src/_shared/backpressure.ts
|
|
108
|
+
import { getLogger as getLogger2 } from "@luckystack/core";
|
|
109
|
+
var createApiFlushPressure = (socket, abortSignal) => {
|
|
110
|
+
return async ({ thresholdBytes } = {}) => {
|
|
111
|
+
const effectiveThresholdBytes = typeof thresholdBytes === "number" && thresholdBytes > 0 ? thresholdBytes : 1048576;
|
|
112
|
+
const packetThreshold = Math.max(1, Math.ceil(effectiveThresholdBytes / 1024));
|
|
113
|
+
while (!abortSignal.aborted) {
|
|
114
|
+
const conn = socket.conn;
|
|
115
|
+
if (shouldLogDev() && !conn) {
|
|
116
|
+
getLogger2().warn("api: socket.conn absent \u2014 engine.io internal may have been renamed; backpressure is a no-op");
|
|
117
|
+
}
|
|
118
|
+
const packets = conn?.writeBuffer?.length ?? 0;
|
|
119
|
+
const writable = conn?.transport?.writable ?? true;
|
|
120
|
+
if (!writable) return;
|
|
121
|
+
if (packets < packetThreshold) return;
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
var httpApiFlushPressureNoop = async () => {
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// src/_shared/requestLifecycle.ts
|
|
130
|
+
var createApiRequestLifecycle = ({ socket, responseIndex, resolvedName }) => {
|
|
131
|
+
const abortController = new AbortController();
|
|
132
|
+
const abortKey = registerApiAbortController(socket.id, responseIndex, abortController);
|
|
133
|
+
const onSocketDisconnect = () => {
|
|
134
|
+
abortController.abort();
|
|
135
|
+
};
|
|
136
|
+
socket.once(socketEventNames.disconnect, onSocketDisconnect);
|
|
137
|
+
let cleanupDone = false;
|
|
138
|
+
const cleanupRequest = () => {
|
|
139
|
+
if (cleanupDone) return;
|
|
140
|
+
cleanupDone = true;
|
|
141
|
+
socket.off(socketEventNames.disconnect, onSocketDisconnect);
|
|
142
|
+
unregisterApiAbortController(abortKey);
|
|
143
|
+
};
|
|
144
|
+
const emitStream = (payload = {}) => {
|
|
145
|
+
if (abortController.signal.aborted) {
|
|
146
|
+
if (shouldLogStream()) {
|
|
147
|
+
getLogger3().debug(`api: ${resolvedName} stream skipped \u2014 request aborted`);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (shouldLogStream()) {
|
|
152
|
+
getLogger3().debug(`api: ${resolvedName} stream`, { payload });
|
|
153
|
+
}
|
|
154
|
+
socket.emit(buildApiStreamEventName(responseIndex), payload);
|
|
155
|
+
};
|
|
156
|
+
const flushPressure = createApiFlushPressure(socket, abortController.signal);
|
|
157
|
+
return { abortController, abortSignal: abortController.signal, emitStream, flushPressure, cleanupRequest };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// src/_shared/socketValidationStage.ts
|
|
161
|
+
import { dispatchHook, validateInputByType, getLogger as getLogger5 } from "@luckystack/core";
|
|
162
|
+
|
|
163
|
+
// src/_shared/inputTypeWarning.ts
|
|
164
|
+
import { getProjectConfig as getProjectConfig2, getLogger as getLogger4 } from "@luckystack/core";
|
|
27
165
|
var warnedMissingInputType = /* @__PURE__ */ new Set();
|
|
28
166
|
var warnIfInputTypeMissing = (resolvedName, inputType) => {
|
|
29
|
-
if (!
|
|
167
|
+
if (!getProjectConfig2().dev.warnOnMissingInputType) return;
|
|
30
168
|
if (inputType && inputType.trim().length > 0 && inputType.trim() !== "any") return;
|
|
31
169
|
if (warnedMissingInputType.has(resolvedName)) return;
|
|
32
170
|
warnedMissingInputType.add(resolvedName);
|
|
33
|
-
|
|
171
|
+
getLogger4().warn(`api: route ${resolvedName} has no inputType \u2014 runtime input validation is disabled. Regenerate types or set the inputType on the handler.`);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/_shared/socketValidationStage.ts
|
|
175
|
+
var resolveValidationMode = (validation) => {
|
|
176
|
+
if (!validation) return "strict";
|
|
177
|
+
if (typeof validation === "string") {
|
|
178
|
+
return validation === "relaxed" ? "relaxed" : "strict";
|
|
179
|
+
}
|
|
180
|
+
return validation.input === "skip" ? "relaxed" : "strict";
|
|
181
|
+
};
|
|
182
|
+
var runSocketApiValidation = async ({
|
|
183
|
+
apiEntry,
|
|
184
|
+
resolvedName,
|
|
185
|
+
normalizedData,
|
|
186
|
+
user,
|
|
187
|
+
cleanupRequest,
|
|
188
|
+
emitInvalidInputType
|
|
189
|
+
}) => {
|
|
190
|
+
const validationMode = resolveValidationMode(apiEntry.validation);
|
|
191
|
+
if (validationMode === "strict") {
|
|
192
|
+
warnIfInputTypeMissing(resolvedName, apiEntry.inputType);
|
|
193
|
+
}
|
|
194
|
+
await dispatchHook("preApiValidate", { routeName: resolvedName, data: normalizedData, user, transport: "socket" });
|
|
195
|
+
if (validationMode === "strict") {
|
|
196
|
+
const inputValidation = await validateInputByType({
|
|
197
|
+
typeText: apiEntry.inputType,
|
|
198
|
+
value: normalizedData,
|
|
199
|
+
rootKey: "data",
|
|
200
|
+
filePath: apiEntry.inputTypeFilePath
|
|
201
|
+
});
|
|
202
|
+
await dispatchHook("postApiValidate", {
|
|
203
|
+
routeName: resolvedName,
|
|
204
|
+
data: normalizedData,
|
|
205
|
+
user,
|
|
206
|
+
validation: inputValidation,
|
|
207
|
+
transport: "socket"
|
|
208
|
+
});
|
|
209
|
+
if (inputValidation.status === "error") {
|
|
210
|
+
if (shouldLogDev()) {
|
|
211
|
+
getLogger5().warn(`api: input validation failed for ${resolvedName}`, { route: resolvedName, message: inputValidation.message });
|
|
212
|
+
}
|
|
213
|
+
cleanupRequest();
|
|
214
|
+
emitInvalidInputType();
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
await dispatchHook("postApiValidate", {
|
|
219
|
+
routeName: resolvedName,
|
|
220
|
+
data: normalizedData,
|
|
221
|
+
user,
|
|
222
|
+
validation: { status: "success" },
|
|
223
|
+
transport: "socket"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// src/_shared/applyApiRateLimits.ts
|
|
230
|
+
import { checkRateLimit, getProjectConfig as getProjectConfig3, dispatchHook as dispatchHook2, getLogger as getLogger6 } from "@luckystack/core";
|
|
231
|
+
|
|
232
|
+
// src/_shared/rateLimitIdentity.ts
|
|
233
|
+
import { deriveTokenBucketId } from "@luckystack/core";
|
|
234
|
+
|
|
235
|
+
// src/_shared/applyApiRateLimits.ts
|
|
236
|
+
var applyApiRateLimits = async ({
|
|
237
|
+
resolvedIp,
|
|
238
|
+
token,
|
|
239
|
+
user,
|
|
240
|
+
resolvedName,
|
|
241
|
+
rateLimit,
|
|
242
|
+
transport,
|
|
243
|
+
skipGlobalIpBucket = false
|
|
244
|
+
}) => {
|
|
245
|
+
const config = getProjectConfig3();
|
|
246
|
+
const effectiveApiLimit = rateLimit ?? config.rateLimiting.defaultApiLimit;
|
|
247
|
+
if (effectiveApiLimit !== false && effectiveApiLimit > 0) {
|
|
248
|
+
const identityCb = config.rateLimiting.identity;
|
|
249
|
+
const customIdentity = identityCb?.({ routeName: resolvedName, userId: user?.id ?? null, ip: resolvedIp, transport }) ?? null;
|
|
250
|
+
const requesterIdentity = customIdentity?.id ?? (token ? deriveTokenBucketId(token) : resolvedIp);
|
|
251
|
+
const keyPrefix = customIdentity?.scope ?? (token ? "token" : "ip");
|
|
252
|
+
const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;
|
|
253
|
+
const { allowed, resetIn } = await checkRateLimit({
|
|
254
|
+
key: rateLimitKey,
|
|
255
|
+
limit: effectiveApiLimit,
|
|
256
|
+
windowMs: config.rateLimiting.windowMs
|
|
257
|
+
});
|
|
258
|
+
if (!allowed) {
|
|
259
|
+
void dispatchHook2("rateLimitExceeded", {
|
|
260
|
+
scope: token ? "user" : "ip",
|
|
261
|
+
key: rateLimitKey,
|
|
262
|
+
limit: effectiveApiLimit,
|
|
263
|
+
windowMs: config.rateLimiting.windowMs,
|
|
264
|
+
count: effectiveApiLimit + 1,
|
|
265
|
+
route: resolvedName,
|
|
266
|
+
userId: user?.id,
|
|
267
|
+
ip: token ? void 0 : resolvedIp
|
|
268
|
+
});
|
|
269
|
+
if (shouldLogDev()) {
|
|
270
|
+
getLogger6().warn(`api: rate limit exceeded for ${resolvedName}`, { route: resolvedName, key: rateLimitKey, transport });
|
|
271
|
+
}
|
|
272
|
+
return { allowed: false, errorCode: "api.rateLimitExceeded", resetIn };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const defaultIpLimit = config.rateLimiting.defaultIpLimit;
|
|
276
|
+
if (!skipGlobalIpBucket && defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
277
|
+
const { allowed, resetIn } = await checkRateLimit({
|
|
278
|
+
key: `ip:${resolvedIp}:api:all`,
|
|
279
|
+
limit: defaultIpLimit,
|
|
280
|
+
windowMs: config.rateLimiting.windowMs
|
|
281
|
+
});
|
|
282
|
+
if (!allowed) {
|
|
283
|
+
void dispatchHook2("rateLimitExceeded", {
|
|
284
|
+
scope: "ip",
|
|
285
|
+
key: `ip:${resolvedIp}:api:all`,
|
|
286
|
+
limit: defaultIpLimit,
|
|
287
|
+
windowMs: config.rateLimiting.windowMs,
|
|
288
|
+
count: defaultIpLimit + 1,
|
|
289
|
+
ip: resolvedIp
|
|
290
|
+
});
|
|
291
|
+
if (shouldLogDev()) {
|
|
292
|
+
getLogger6().warn(`api: global IP rate limit exceeded`, { ip: resolvedIp, transport });
|
|
293
|
+
}
|
|
294
|
+
return { allowed: false, errorCode: "api.rateLimitExceeded", resetIn };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { allowed: true };
|
|
34
298
|
};
|
|
299
|
+
|
|
300
|
+
// src/handleApiRequest.ts
|
|
35
301
|
var validateApiMessage = ({ msg, emitApiError }) => {
|
|
36
302
|
const { name, data, responseIndex } = msg;
|
|
37
|
-
if (
|
|
303
|
+
if (typeof responseIndex !== "number") {
|
|
38
304
|
if (shouldLogDev()) {
|
|
39
|
-
|
|
305
|
+
getLogger7().warn("api: no response index given");
|
|
40
306
|
}
|
|
41
307
|
return null;
|
|
42
308
|
}
|
|
43
|
-
if (!name || !data || typeof name != "string" || typeof data != "object") {
|
|
309
|
+
if (!name || !data || typeof name != "string" || typeof data != "object" || Array.isArray(data)) {
|
|
44
310
|
emitApiError({
|
|
45
311
|
response: { status: "error", errorCode: "api.invalidRequest" },
|
|
46
312
|
fallbackHttpStatus: 400
|
|
@@ -52,8 +318,14 @@ var validateApiMessage = ({ msg, emitApiError }) => {
|
|
|
52
318
|
var checkApiAuth = ({ apiEntry, user, name, emitApiError }) => {
|
|
53
319
|
if (apiEntry.auth.login && !user?.id) {
|
|
54
320
|
if (shouldLogDev()) {
|
|
55
|
-
|
|
321
|
+
getLogger7().warn(`api: ${name} requires login`, { route: name });
|
|
56
322
|
}
|
|
323
|
+
void dispatchHook3("apiAuthRejected", {
|
|
324
|
+
routeName: name,
|
|
325
|
+
reason: "login-required",
|
|
326
|
+
userId: null,
|
|
327
|
+
transport: "socket"
|
|
328
|
+
});
|
|
57
329
|
emitApiError({
|
|
58
330
|
response: { status: "error", errorCode: "auth.required" },
|
|
59
331
|
fallbackHttpStatus: 401
|
|
@@ -63,11 +335,19 @@ var checkApiAuth = ({ apiEntry, user, name, emitApiError }) => {
|
|
|
63
335
|
const authResult = validateRequest({ auth: apiEntry.auth, user });
|
|
64
336
|
if (authResult.status === "error") {
|
|
65
337
|
if (shouldLogDev()) {
|
|
66
|
-
|
|
338
|
+
getLogger7().warn(`api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode });
|
|
67
339
|
}
|
|
340
|
+
void dispatchHook3("apiAuthRejected", {
|
|
341
|
+
routeName: name,
|
|
342
|
+
reason: authResult.errorCode === "auth.misconfiguredPredicate" ? "invalid-condition" : "additional-failed",
|
|
343
|
+
userId: user?.id ?? null,
|
|
344
|
+
transport: "socket",
|
|
345
|
+
failedKey: authResult.errorCode
|
|
346
|
+
});
|
|
68
347
|
emitApiError({
|
|
69
348
|
response: {
|
|
70
349
|
status: "error",
|
|
350
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string errorCode must still fall back
|
|
71
351
|
errorCode: authResult.errorCode || "auth.forbidden",
|
|
72
352
|
errorParams: authResult.errorParams,
|
|
73
353
|
httpStatus: authResult.httpStatus
|
|
@@ -78,78 +358,47 @@ var checkApiAuth = ({ apiEntry, user, name, emitApiError }) => {
|
|
|
78
358
|
}
|
|
79
359
|
return true;
|
|
80
360
|
};
|
|
81
|
-
var
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
getLogger().warn(`api: rate limit exceeded for ${resolvedName}`, { route: resolvedName, key: rateLimitKey });
|
|
105
|
-
}
|
|
106
|
-
emitApiError({
|
|
107
|
-
response: {
|
|
108
|
-
status: "error",
|
|
109
|
-
errorCode: "api.rateLimitExceeded",
|
|
110
|
-
errorParams: [{ key: "seconds", value: resetIn }]
|
|
111
|
-
},
|
|
112
|
-
fallbackHttpStatus: 429
|
|
113
|
-
});
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
const defaultIpLimit = getProjectConfig().rateLimiting.defaultIpLimit;
|
|
118
|
-
if (defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
119
|
-
const requesterIp = socket.handshake.address ?? "unknown";
|
|
120
|
-
const { allowed, resetIn } = await checkRateLimit({
|
|
121
|
-
key: `ip:${requesterIp}:api:all`,
|
|
122
|
-
limit: defaultIpLimit,
|
|
123
|
-
windowMs: getProjectConfig().rateLimiting.windowMs
|
|
361
|
+
var runSocketRateLimits = async ({ apiEntry, resolvedName, token, socket, user, emitApiError }) => {
|
|
362
|
+
const resolvedIp = resolveClientIp({
|
|
363
|
+
rawAddress: socket.handshake.address,
|
|
364
|
+
headers: socket.handshake.headers,
|
|
365
|
+
trustProxy: getProjectConfig4().http.trustProxy,
|
|
366
|
+
trustedProxyHopCount: getProjectConfig4().http.trustedProxyHopCount
|
|
367
|
+
});
|
|
368
|
+
const result = await applyApiRateLimits({
|
|
369
|
+
resolvedIp,
|
|
370
|
+
token,
|
|
371
|
+
user,
|
|
372
|
+
resolvedName,
|
|
373
|
+
rateLimit: apiEntry.rateLimit,
|
|
374
|
+
transport: "socket"
|
|
375
|
+
});
|
|
376
|
+
if (!result.allowed) {
|
|
377
|
+
emitApiError({
|
|
378
|
+
response: {
|
|
379
|
+
status: "error",
|
|
380
|
+
errorCode: result.errorCode ?? "api.rateLimitExceeded",
|
|
381
|
+
errorParams: [{ key: "seconds", value: result.resetIn ?? 0 }]
|
|
382
|
+
},
|
|
383
|
+
fallbackHttpStatus: 429
|
|
124
384
|
});
|
|
125
|
-
|
|
126
|
-
void dispatchHook("rateLimitExceeded", {
|
|
127
|
-
scope: "ip",
|
|
128
|
-
key: `ip:${requesterIp}:api:all`,
|
|
129
|
-
limit: defaultIpLimit,
|
|
130
|
-
windowMs: getProjectConfig().rateLimiting.windowMs,
|
|
131
|
-
count: defaultIpLimit + 1,
|
|
132
|
-
ip: requesterIp
|
|
133
|
-
});
|
|
134
|
-
if (shouldLogDev()) {
|
|
135
|
-
getLogger().warn(`api: global IP rate limit exceeded`, { ip: requesterIp });
|
|
136
|
-
}
|
|
137
|
-
emitApiError({
|
|
138
|
-
response: {
|
|
139
|
-
status: "error",
|
|
140
|
-
errorCode: "api.rateLimitExceeded",
|
|
141
|
-
errorParams: [{ key: "seconds", value: resetIn }]
|
|
142
|
-
},
|
|
143
|
-
fallbackHttpStatus: 429
|
|
144
|
-
});
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
385
|
+
return false;
|
|
147
386
|
}
|
|
148
387
|
return true;
|
|
149
388
|
};
|
|
150
|
-
var executeApiHandler = async ({ apiEntry, normalizedData, user, functionsObject, resolvedName, emitStream, abortSignal, flushPressure }) => {
|
|
389
|
+
var executeApiHandler = async ({ apiEntry, normalizedData, user, functionsObject, resolvedName, emitStream, abortSignal, abortController, flushPressure }) => {
|
|
151
390
|
const executeStart = Date.now();
|
|
152
|
-
const
|
|
391
|
+
const timeoutMs = getProjectConfig4().api.requestTimeoutMs;
|
|
392
|
+
let timeoutHandle;
|
|
393
|
+
let timedOut = false;
|
|
394
|
+
const timeoutPromise = timeoutMs === false ? null : new Promise((resolve) => {
|
|
395
|
+
timeoutHandle = setTimeout(() => {
|
|
396
|
+
timedOut = true;
|
|
397
|
+
abortController.abort();
|
|
398
|
+
resolve();
|
|
399
|
+
}, timeoutMs);
|
|
400
|
+
});
|
|
401
|
+
const handlerPromise = tryCatch(
|
|
153
402
|
async () => await apiEntry.main({ data: normalizedData, user, functions: functionsObject, stream: emitStream, abortSignal, flushPressure }),
|
|
154
403
|
void 0,
|
|
155
404
|
{
|
|
@@ -159,67 +408,59 @@ var executeApiHandler = async ({ apiEntry, normalizedData, user, functionsObject
|
|
|
159
408
|
transport: "socket"
|
|
160
409
|
}
|
|
161
410
|
);
|
|
411
|
+
if (timeoutPromise) {
|
|
412
|
+
await Promise.race([handlerPromise, timeoutPromise]);
|
|
413
|
+
}
|
|
414
|
+
clearTimeout(timeoutHandle);
|
|
415
|
+
if (timedOut) {
|
|
416
|
+
return { error: null, result: void 0, durationMs: Date.now() - executeStart, timedOut: true };
|
|
417
|
+
}
|
|
418
|
+
const [error, result] = await handlerPromise;
|
|
162
419
|
return { error: error ?? null, result: result ?? void 0, durationMs: Date.now() - executeStart };
|
|
163
420
|
};
|
|
164
|
-
var
|
|
421
|
+
var runSocketApiExecution = async ({
|
|
422
|
+
apiEntry,
|
|
165
423
|
resolvedName,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
424
|
+
normalizedData,
|
|
425
|
+
user,
|
|
426
|
+
functionsObject,
|
|
427
|
+
emitStream,
|
|
428
|
+
abortSignal,
|
|
429
|
+
abortController,
|
|
430
|
+
flushPressure
|
|
170
431
|
}) => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (shouldLogDev()) {
|
|
184
|
-
getLogger().warn(`api: ${resolvedName} returned nothing`);
|
|
185
|
-
}
|
|
186
|
-
return { ...normalizeErrorResponse({
|
|
187
|
-
response: { status: "error", errorCode: "api.emptyResponse" },
|
|
188
|
-
preferredLocale,
|
|
189
|
-
userLanguage: user?.language,
|
|
190
|
-
fallbackHttpStatus: 500
|
|
191
|
-
}) };
|
|
192
|
-
}
|
|
193
|
-
if (shouldLogDev()) {
|
|
194
|
-
getLogger().debug(`api: ${resolvedName} completed`);
|
|
195
|
-
}
|
|
196
|
-
if (result.status !== "success" && result.status !== "error") {
|
|
197
|
-
return { ...normalizeErrorResponse({
|
|
198
|
-
response: { status: "error", errorCode: "api.invalidResponseStatus" },
|
|
199
|
-
preferredLocale,
|
|
200
|
-
userLanguage: user?.language,
|
|
201
|
-
fallbackHttpStatus: 500
|
|
202
|
-
}) };
|
|
432
|
+
const executePayload = {
|
|
433
|
+
routeName: resolvedName,
|
|
434
|
+
data: normalizedData,
|
|
435
|
+
user,
|
|
436
|
+
transport: "socket",
|
|
437
|
+
result: void 0,
|
|
438
|
+
error: null,
|
|
439
|
+
durationMs: 0
|
|
440
|
+
};
|
|
441
|
+
const preExecuteResult = await dispatchHook3("preApiExecute", executePayload);
|
|
442
|
+
if (preExecuteResult.stopped) {
|
|
443
|
+
return { stopped: true, errorCode: preExecuteResult.signal.errorCode, httpStatus: preExecuteResult.signal.httpStatus };
|
|
203
444
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
445
|
+
const { error, result, durationMs, timedOut } = await executeApiHandler({
|
|
446
|
+
apiEntry,
|
|
447
|
+
normalizedData,
|
|
448
|
+
user,
|
|
449
|
+
functionsObject,
|
|
450
|
+
resolvedName,
|
|
451
|
+
emitStream,
|
|
452
|
+
abortSignal,
|
|
453
|
+
abortController,
|
|
454
|
+
flushPressure
|
|
455
|
+
});
|
|
456
|
+
if (timedOut) {
|
|
457
|
+
return { stopped: true, errorCode: "api.timeout", httpStatus: 504 };
|
|
214
458
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
221
|
-
})
|
|
222
|
-
};
|
|
459
|
+
executePayload.result = result;
|
|
460
|
+
executePayload.error = error ?? null;
|
|
461
|
+
executePayload.durationMs = durationMs;
|
|
462
|
+
await dispatchHook3("postApiExecute", executePayload);
|
|
463
|
+
return { stopped: false, error, result };
|
|
223
464
|
};
|
|
224
465
|
var emitApiResult = async ({
|
|
225
466
|
socket,
|
|
@@ -231,17 +472,17 @@ var emitApiResult = async ({
|
|
|
231
472
|
user,
|
|
232
473
|
perRouteFormatter
|
|
233
474
|
}) => {
|
|
234
|
-
const envelope =
|
|
475
|
+
const envelope = normalizeApiResponse({ resolvedName, error, result, preferredLocale, user });
|
|
235
476
|
const preRespond = { routeName: resolvedName, user, response: envelope };
|
|
236
|
-
const preRespondResult = await
|
|
237
|
-
const finalResponse = preRespondResult.stopped ? { ...
|
|
477
|
+
const preRespondResult = await dispatchHook3("preApiRespond", preRespond);
|
|
478
|
+
const finalResponse = preRespondResult.stopped ? { ...normalizeErrorResponse2({
|
|
238
479
|
response: { status: "error", errorCode: preRespondResult.signal.errorCode },
|
|
239
480
|
preferredLocale,
|
|
240
481
|
userLanguage: user?.language,
|
|
241
482
|
fallbackHttpStatus: preRespondResult.signal.httpStatus ?? 403
|
|
242
483
|
}) } : preRespond.response;
|
|
243
484
|
const transformPayload = { routeName: resolvedName, user, response: finalResponse };
|
|
244
|
-
await
|
|
485
|
+
await dispatchHook3("transformApiResponse", transformPayload);
|
|
245
486
|
const formattedResponse = applyErrorFormatter({
|
|
246
487
|
response: transformPayload.response,
|
|
247
488
|
routeName: resolvedName,
|
|
@@ -250,28 +491,48 @@ var emitApiResult = async ({
|
|
|
250
491
|
perRouteFormatter
|
|
251
492
|
});
|
|
252
493
|
socket.emit(buildApiResponseEventName(responseIndex), formattedResponse);
|
|
253
|
-
await
|
|
494
|
+
await dispatchHook3("postApiRespond", { routeName: resolvedName, user, response: formattedResponse });
|
|
254
495
|
};
|
|
255
|
-
async function handleApiRequest(
|
|
496
|
+
async function handleApiRequest(args) {
|
|
497
|
+
await runWithErrorTrackerIdentityScope(() => handleApiRequestInner(args));
|
|
498
|
+
}
|
|
499
|
+
async function handleApiRequestInner({ msg, socket, token }) {
|
|
256
500
|
if (typeof msg != "object") {
|
|
257
501
|
if (shouldLogDev()) {
|
|
258
|
-
|
|
502
|
+
getLogger7().warn("api: socket message was not a json object");
|
|
259
503
|
}
|
|
260
504
|
return;
|
|
261
505
|
}
|
|
262
506
|
const { responseIndex } = msg;
|
|
263
|
-
const
|
|
264
|
-
|
|
507
|
+
const preMsgResult = await dispatchHook3("preSocketMessage", {
|
|
508
|
+
channel: "api",
|
|
509
|
+
socketId: socket.id,
|
|
510
|
+
ip: socket.handshake.address,
|
|
511
|
+
authenticated: Boolean(token),
|
|
512
|
+
routeName: typeof msg.name === "string" ? msg.name : void 0
|
|
513
|
+
});
|
|
514
|
+
if (preMsgResult.stopped) {
|
|
515
|
+
socket.emit(buildApiResponseEventName(responseIndex), {
|
|
516
|
+
status: "error",
|
|
517
|
+
errorCode: preMsgResult.signal.errorCode,
|
|
518
|
+
httpStatus: preMsgResult.signal.httpStatus ?? 403
|
|
519
|
+
});
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const user = await readSession(token);
|
|
523
|
+
setCurrentErrorTrackerIdentity(user?.id ? { id: user.id, email: user.email ?? void 0, username: user.name ?? void 0 } : null);
|
|
524
|
+
const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) ?? extractLanguageFromHeader(socket.handshake.headers["accept-language"]) ?? void 0;
|
|
265
525
|
let currentRouteName;
|
|
266
526
|
let currentPerRouteFormatter;
|
|
267
527
|
const emitApiError = ({ response, fallbackHttpStatus }) => {
|
|
268
|
-
const normalized =
|
|
528
|
+
const normalized = normalizeErrorResponse2({
|
|
269
529
|
response,
|
|
270
530
|
preferredLocale,
|
|
271
531
|
userLanguage: user?.language,
|
|
272
532
|
fallbackHttpStatus
|
|
273
533
|
});
|
|
274
534
|
const formatted = applyErrorFormatter({
|
|
535
|
+
// eslint-disable-next-line no-restricted-syntax -- formatter boundary cast, mirrors handleHttpApiRequest
|
|
275
536
|
response: normalized,
|
|
276
537
|
routeName: currentRouteName ?? "api/unknown",
|
|
277
538
|
transport: "socket",
|
|
@@ -285,7 +546,7 @@ async function handleApiRequest({ msg, socket, token }) {
|
|
|
285
546
|
const { name, data: normalizedData } = validated;
|
|
286
547
|
const parsedRoute = parseTransportRouteName({ value: name, prefix: "api" });
|
|
287
548
|
if (parsedRoute.status === "error") {
|
|
288
|
-
|
|
549
|
+
emitApiError({
|
|
289
550
|
response: {
|
|
290
551
|
status: "error",
|
|
291
552
|
errorCode: "routing.invalidServiceRouteName",
|
|
@@ -293,209 +554,209 @@ async function handleApiRequest({ msg, socket, token }) {
|
|
|
293
554
|
},
|
|
294
555
|
fallbackHttpStatus: 400
|
|
295
556
|
});
|
|
557
|
+
return;
|
|
296
558
|
}
|
|
297
559
|
const resolvedName = parsedRoute.normalizedFullName;
|
|
298
560
|
currentRouteName = resolvedName;
|
|
299
561
|
if (parsedRoute.serviceRoute.normalizedRouteName === "system/logout") {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
562
|
+
const logoutIpLimit = getProjectConfig4().rateLimiting.defaultIpLimit;
|
|
563
|
+
if (logoutIpLimit !== false && logoutIpLimit > 0) {
|
|
564
|
+
const logoutIp = resolveClientIp({
|
|
565
|
+
rawAddress: socket.handshake.address,
|
|
566
|
+
headers: socket.handshake.headers,
|
|
567
|
+
trustProxy: getProjectConfig4().http.trustProxy,
|
|
568
|
+
trustedProxyHopCount: getProjectConfig4().http.trustedProxyHopCount
|
|
569
|
+
});
|
|
570
|
+
const { allowed, resetIn } = await checkRateLimit2({
|
|
571
|
+
key: `ip:${logoutIp}:api:all`,
|
|
572
|
+
limit: logoutIpLimit,
|
|
573
|
+
windowMs: getProjectConfig4().rateLimiting.windowMs
|
|
574
|
+
});
|
|
575
|
+
if (!allowed) {
|
|
576
|
+
void dispatchHook3("rateLimitExceeded", {
|
|
577
|
+
scope: "ip",
|
|
578
|
+
key: `ip:${logoutIp}:api:all`,
|
|
579
|
+
limit: logoutIpLimit,
|
|
580
|
+
windowMs: getProjectConfig4().rateLimiting.windowMs,
|
|
581
|
+
count: logoutIpLimit + 1,
|
|
582
|
+
ip: logoutIp
|
|
583
|
+
});
|
|
584
|
+
emitApiError({
|
|
585
|
+
response: {
|
|
586
|
+
status: "error",
|
|
587
|
+
errorCode: "api.rateLimitExceeded",
|
|
588
|
+
errorParams: [{ key: "seconds", value: resetIn }]
|
|
589
|
+
},
|
|
590
|
+
fallbackHttpStatus: 429
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
await performLogout({ token, socket, userId: user?.id ?? null });
|
|
596
|
+
await emitApiResult({
|
|
597
|
+
socket,
|
|
598
|
+
responseIndex,
|
|
599
|
+
resolvedName,
|
|
600
|
+
error: null,
|
|
601
|
+
result: { status: "success", httpStatus: 200, result: true },
|
|
602
|
+
preferredLocale,
|
|
603
|
+
user
|
|
305
604
|
});
|
|
605
|
+
return;
|
|
306
606
|
}
|
|
307
607
|
if (shouldLogDev()) {
|
|
308
|
-
|
|
608
|
+
getLogger7().debug(`api: ${resolvedName} called`);
|
|
309
609
|
}
|
|
310
610
|
const { apisObject, functionsObject } = await getRuntimeApiMaps();
|
|
311
611
|
if (!apisObject[resolvedName]) {
|
|
312
|
-
|
|
612
|
+
emitApiError({
|
|
313
613
|
response: {
|
|
314
614
|
status: "error",
|
|
315
615
|
errorCode: "api.notFound",
|
|
316
|
-
errorParams: [{ key: "name", value: name }]
|
|
317
|
-
},
|
|
318
|
-
fallbackHttpStatus: 404
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
const apiEntry = apisObject[resolvedName];
|
|
322
|
-
currentPerRouteFormatter = apiEntry.errorFormatter;
|
|
323
|
-
const abortController = new AbortController();
|
|
324
|
-
const abortKey = registerApiAbortController(socket.id, responseIndex, abortController);
|
|
325
|
-
const onSocketDisconnect = () => {
|
|
326
|
-
abortController.abort();
|
|
327
|
-
};
|
|
328
|
-
socket.once(socketEventNames.disconnect, onSocketDisconnect);
|
|
329
|
-
let cleanupDone = false;
|
|
330
|
-
const cleanupRequest = () => {
|
|
331
|
-
if (cleanupDone) return;
|
|
332
|
-
cleanupDone = true;
|
|
333
|
-
socket.off(socketEventNames.disconnect, onSocketDisconnect);
|
|
334
|
-
unregisterApiAbortController(abortKey);
|
|
335
|
-
};
|
|
336
|
-
const emitStream = (payload = {}) => {
|
|
337
|
-
if (abortController.signal.aborted) {
|
|
338
|
-
if (shouldLogStream()) {
|
|
339
|
-
getLogger().debug(`api: ${resolvedName} stream skipped \u2014 request aborted`);
|
|
340
|
-
}
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (shouldLogStream()) {
|
|
344
|
-
getLogger().debug(`api: ${resolvedName} stream`, { payload });
|
|
345
|
-
}
|
|
346
|
-
socket.emit(buildApiStreamEventName(responseIndex), payload);
|
|
347
|
-
};
|
|
348
|
-
const apiFlushPressure = async ({ thresholdBytes } = {}) => {
|
|
349
|
-
if (abortController.signal.aborted) return;
|
|
350
|
-
const effectiveThresholdBytes = typeof thresholdBytes === "number" && thresholdBytes > 0 ? thresholdBytes : 1048576;
|
|
351
|
-
const packetThreshold = Math.max(1, Math.ceil(effectiveThresholdBytes / 1024));
|
|
352
|
-
while (true) {
|
|
353
|
-
if (abortController.signal.aborted) return;
|
|
354
|
-
const conn = socket.conn;
|
|
355
|
-
const packets = conn?.writeBuffer?.length ?? 0;
|
|
356
|
-
const writable = conn?.transport?.writable ?? true;
|
|
357
|
-
if (!writable) return;
|
|
358
|
-
if (packets < packetThreshold) return;
|
|
359
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
if (!checkApiAuth({ apiEntry, user, name, emitApiError })) {
|
|
363
|
-
cleanupRequest();
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
const rateLimitOk = await applyApiRateLimits({ apiEntry, resolvedName, token, socket, user, emitApiError });
|
|
367
|
-
if (!rateLimitOk) {
|
|
368
|
-
cleanupRequest();
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const validationMode = (() => {
|
|
372
|
-
const v = apiEntry.validation;
|
|
373
|
-
if (!v) return "strict";
|
|
374
|
-
if (typeof v === "string") return v;
|
|
375
|
-
return v.input === "skip" ? "relaxed" : "strict";
|
|
376
|
-
})();
|
|
377
|
-
if (validationMode === "strict") {
|
|
378
|
-
warnIfInputTypeMissing(resolvedName, apiEntry.inputType);
|
|
379
|
-
}
|
|
380
|
-
await dispatchHook("preApiValidate", { routeName: resolvedName, data: normalizedData, user, transport: "socket" });
|
|
381
|
-
if (validationMode === "strict") {
|
|
382
|
-
const inputValidation = await validateInputByType({
|
|
383
|
-
typeText: apiEntry.inputType,
|
|
384
|
-
value: normalizedData,
|
|
385
|
-
rootKey: "data",
|
|
386
|
-
filePath: apiEntry.inputTypeFilePath
|
|
387
|
-
});
|
|
388
|
-
await dispatchHook("postApiValidate", {
|
|
389
|
-
routeName: resolvedName,
|
|
390
|
-
data: normalizedData,
|
|
391
|
-
user,
|
|
392
|
-
validation: inputValidation,
|
|
393
|
-
transport: "socket"
|
|
394
|
-
});
|
|
395
|
-
if (inputValidation.status === "error") {
|
|
396
|
-
cleanupRequest();
|
|
397
|
-
return emitApiError({
|
|
398
|
-
response: {
|
|
399
|
-
status: "error",
|
|
400
|
-
errorCode: "api.invalidInputType",
|
|
401
|
-
errorParams: [{ key: "message", value: inputValidation.message }]
|
|
402
|
-
},
|
|
403
|
-
fallbackHttpStatus: 400
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
await dispatchHook("postApiValidate", {
|
|
408
|
-
routeName: resolvedName,
|
|
409
|
-
data: normalizedData,
|
|
410
|
-
user,
|
|
411
|
-
validation: { status: "success" },
|
|
412
|
-
transport: "socket"
|
|
616
|
+
errorParams: [{ key: "name", value: name }]
|
|
617
|
+
},
|
|
618
|
+
fallbackHttpStatus: 404
|
|
413
619
|
});
|
|
620
|
+
return;
|
|
414
621
|
}
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
};
|
|
424
|
-
const preExecuteResult = await dispatchHook("preApiExecute", executePayload);
|
|
425
|
-
if (preExecuteResult.stopped) {
|
|
622
|
+
const apiEntry = apisObject[resolvedName];
|
|
623
|
+
currentPerRouteFormatter = apiEntry.errorFormatter;
|
|
624
|
+
const { abortController, abortSignal, emitStream, flushPressure, cleanupRequest } = createApiRequestLifecycle({
|
|
625
|
+
socket,
|
|
626
|
+
responseIndex,
|
|
627
|
+
resolvedName
|
|
628
|
+
});
|
|
629
|
+
if (!checkApiAuth({ apiEntry, user, name, emitApiError })) {
|
|
426
630
|
cleanupRequest();
|
|
427
|
-
return
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const rateLimitOk = await runSocketRateLimits({ apiEntry, resolvedName, token, socket, user, emitApiError });
|
|
634
|
+
if (!rateLimitOk) {
|
|
635
|
+
cleanupRequest();
|
|
636
|
+
return;
|
|
431
637
|
}
|
|
432
|
-
const
|
|
638
|
+
const validationOk = await runSocketApiValidation({
|
|
433
639
|
apiEntry,
|
|
640
|
+
resolvedName,
|
|
434
641
|
normalizedData,
|
|
435
642
|
user,
|
|
436
|
-
|
|
643
|
+
cleanupRequest,
|
|
644
|
+
emitInvalidInputType: () => {
|
|
645
|
+
emitApiError({
|
|
646
|
+
response: { status: "error", errorCode: "api.invalidInputType" },
|
|
647
|
+
fallbackHttpStatus: 400
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
if (!validationOk) return;
|
|
652
|
+
const execution = await runSocketApiExecution({
|
|
653
|
+
apiEntry,
|
|
437
654
|
resolvedName,
|
|
655
|
+
normalizedData,
|
|
656
|
+
user,
|
|
657
|
+
functionsObject,
|
|
438
658
|
emitStream,
|
|
439
|
-
abortSignal
|
|
440
|
-
|
|
659
|
+
abortSignal,
|
|
660
|
+
abortController,
|
|
661
|
+
flushPressure
|
|
441
662
|
});
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
663
|
+
if (execution.stopped) {
|
|
664
|
+
cleanupRequest();
|
|
665
|
+
emitApiError({
|
|
666
|
+
response: { status: "error", errorCode: execution.errorCode },
|
|
667
|
+
fallbackHttpStatus: execution.httpStatus ?? 403
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
await emitApiResult({ socket, responseIndex, resolvedName, error: execution.error, result: execution.result, preferredLocale, user, perRouteFormatter: apiEntry.errorFormatter });
|
|
447
672
|
cleanupRequest();
|
|
448
673
|
}
|
|
449
674
|
|
|
450
675
|
// src/handleHttpApiRequest.ts
|
|
451
|
-
import { getSession as getSession2 } from "@luckystack/login";
|
|
452
676
|
import {
|
|
453
|
-
getProjectConfig as
|
|
677
|
+
getProjectConfig as getProjectConfig5,
|
|
678
|
+
readSession as readSession2,
|
|
454
679
|
getRuntimeApiMaps as getRuntimeApiMapsFromSource,
|
|
680
|
+
runWithErrorTrackerIdentityScope as runWithErrorTrackerIdentityScope2,
|
|
681
|
+
setCurrentErrorTrackerIdentity as setCurrentErrorTrackerIdentity2,
|
|
455
682
|
validateRequest as validateRequest2,
|
|
456
|
-
checkRateLimit as checkRateLimit2,
|
|
457
683
|
inferHttpMethod,
|
|
458
684
|
tryCatch as tryCatch2,
|
|
459
685
|
parseTransportRouteName as parseTransportRouteName2,
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
getLogger as getLogger2,
|
|
463
|
-
defaultHttpStatusForResponse as defaultHttpStatusForResponse2,
|
|
686
|
+
dispatchHook as dispatchHook5,
|
|
687
|
+
getLogger as getLogger9,
|
|
464
688
|
extractLanguageFromHeader as extractLanguageFromHeader2,
|
|
465
|
-
normalizeErrorResponse as
|
|
466
|
-
applyErrorFormatter as applyErrorFormatter2
|
|
689
|
+
normalizeErrorResponse as normalizeErrorResponse3,
|
|
690
|
+
applyErrorFormatter as applyErrorFormatter2,
|
|
691
|
+
isLoopbackIp,
|
|
692
|
+
resolveClientIp as resolveClientIp2
|
|
467
693
|
} from "@luckystack/core";
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
694
|
+
|
|
695
|
+
// src/_shared/httpValidationStage.ts
|
|
696
|
+
import { dispatchHook as dispatchHook4, validateInputByType as validateInputByType2, getLogger as getLogger8 } from "@luckystack/core";
|
|
697
|
+
var runHttpApiValidation = async ({
|
|
698
|
+
resolvedName,
|
|
699
|
+
inputType,
|
|
700
|
+
inputTypeFilePath,
|
|
701
|
+
requestData,
|
|
702
|
+
user,
|
|
703
|
+
validation
|
|
704
|
+
}) => {
|
|
705
|
+
const validationMode = resolveValidationMode(validation);
|
|
706
|
+
if (validationMode === "strict") {
|
|
707
|
+
warnIfInputTypeMissing(resolvedName, inputType);
|
|
473
708
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
709
|
+
await dispatchHook4("preApiValidate", { routeName: resolvedName, data: requestData, user, transport: "http" });
|
|
710
|
+
if (validationMode === "relaxed") {
|
|
711
|
+
await dispatchHook4("postApiValidate", {
|
|
712
|
+
routeName: resolvedName,
|
|
713
|
+
data: requestData,
|
|
714
|
+
user,
|
|
715
|
+
validation: { status: "success" },
|
|
716
|
+
transport: "http"
|
|
717
|
+
});
|
|
718
|
+
return { ok: true };
|
|
719
|
+
}
|
|
720
|
+
const inputValidation = await validateInputByType2({
|
|
721
|
+
typeText: inputType,
|
|
722
|
+
value: requestData,
|
|
723
|
+
rootKey: "data",
|
|
724
|
+
filePath: inputTypeFilePath
|
|
725
|
+
});
|
|
726
|
+
await dispatchHook4("postApiValidate", {
|
|
727
|
+
routeName: resolvedName,
|
|
728
|
+
data: requestData,
|
|
729
|
+
user,
|
|
730
|
+
validation: inputValidation,
|
|
731
|
+
transport: "http"
|
|
732
|
+
});
|
|
733
|
+
if (inputValidation.status === "error") {
|
|
734
|
+
if (shouldLogDev()) {
|
|
735
|
+
getLogger8().warn(`http-api: input validation failed for ${resolvedName}`, { route: resolvedName, message: inputValidation.message, transport: "http" });
|
|
736
|
+
}
|
|
737
|
+
return { ok: false };
|
|
738
|
+
}
|
|
739
|
+
return { ok: true };
|
|
486
740
|
};
|
|
741
|
+
|
|
742
|
+
// src/handleHttpApiRequest.ts
|
|
487
743
|
async function handleHttpApiRequest(params) {
|
|
744
|
+
return runWithErrorTrackerIdentityScope2(() => handleHttpApiRequestScoped(params));
|
|
745
|
+
}
|
|
746
|
+
async function handleHttpApiRequestScoped(params) {
|
|
488
747
|
const { response, user, preferredLocale, perRouteFormatter } = await runHandleHttpApiRequest(params);
|
|
489
748
|
const routeNameForHook = params.name.startsWith("api/") ? params.name : `api/${params.name}`;
|
|
490
749
|
const preRespond = { routeName: routeNameForHook, user, response };
|
|
491
|
-
const preRespondResult = await
|
|
492
|
-
const finalResponse = preRespondResult.stopped ?
|
|
750
|
+
const preRespondResult = await dispatchHook5("preApiRespond", preRespond);
|
|
751
|
+
const finalResponse = preRespondResult.stopped ? normalizeErrorResponse3({
|
|
493
752
|
response: { status: "error", errorCode: preRespondResult.signal.errorCode },
|
|
494
753
|
preferredLocale,
|
|
495
754
|
userLanguage: user?.language,
|
|
496
755
|
fallbackHttpStatus: preRespondResult.signal.httpStatus ?? 403
|
|
497
756
|
}) : preRespond.response;
|
|
498
|
-
const
|
|
757
|
+
const transformPayload = { routeName: routeNameForHook, user, response: finalResponse };
|
|
758
|
+
await dispatchHook5("transformApiResponse", transformPayload);
|
|
759
|
+
const formatterInput = transformPayload.response;
|
|
499
760
|
const formattedResponse = applyErrorFormatter2({
|
|
500
761
|
response: formatterInput,
|
|
501
762
|
routeName: routeNameForHook,
|
|
@@ -503,57 +764,34 @@ async function handleHttpApiRequest(params) {
|
|
|
503
764
|
userId: user?.id,
|
|
504
765
|
perRouteFormatter
|
|
505
766
|
});
|
|
506
|
-
await
|
|
767
|
+
await dispatchHook5("postApiRespond", { routeName: routeNameForHook, user, response: formattedResponse });
|
|
507
768
|
return formattedResponse;
|
|
508
769
|
}
|
|
509
770
|
async function runHandleHttpApiRequest(params) {
|
|
510
771
|
const preferredLocale = extractLanguageFromHeader2(params.xLanguageHeader) ?? extractLanguageFromHeader2(params.acceptLanguageHeader);
|
|
511
|
-
const user = await
|
|
772
|
+
const user = await readSession2(params.token);
|
|
773
|
+
setCurrentErrorTrackerIdentity2(user?.id ? { id: user.id, email: user.email ?? void 0, username: user.name ?? void 0 } : null);
|
|
512
774
|
const formatterHolder = {};
|
|
513
775
|
const response = await runHandleHttpApiRequestInner(params, user, preferredLocale, formatterHolder);
|
|
514
776
|
return { response, user, preferredLocale, perRouteFormatter: formatterHolder.current };
|
|
515
777
|
}
|
|
516
|
-
async function
|
|
517
|
-
name,
|
|
518
|
-
data,
|
|
519
|
-
token,
|
|
520
|
-
requesterIp,
|
|
521
|
-
xLanguageHeader: _xLanguageHeader,
|
|
522
|
-
acceptLanguageHeader: _acceptLanguageHeader,
|
|
523
|
-
method = "POST",
|
|
524
|
-
stream,
|
|
525
|
-
abortSignal
|
|
526
|
-
}, user, preferredLocale, formatterHolder) {
|
|
527
|
-
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
|
|
528
|
-
const flushPressure = httpApiFlushPressureNoop;
|
|
529
|
-
const normalizedName = name.startsWith("api/") ? name : `api/${name}`;
|
|
530
|
-
const buildNetworkError = ({
|
|
531
|
-
response,
|
|
532
|
-
fallbackHttpStatus
|
|
533
|
-
}) => {
|
|
534
|
-
return normalizeErrorResponse2({
|
|
535
|
-
response,
|
|
536
|
-
preferredLocale,
|
|
537
|
-
userLanguage: user?.language,
|
|
538
|
-
fallbackHttpStatus
|
|
539
|
-
});
|
|
540
|
-
};
|
|
778
|
+
async function validateHttpApiRequestShape(name, data, ctx) {
|
|
541
779
|
if (!name || typeof name !== "string") {
|
|
542
|
-
return buildNetworkError({
|
|
780
|
+
return ctx.buildNetworkError({
|
|
543
781
|
response: { status: "error", errorCode: "api.invalidName" },
|
|
544
782
|
fallbackHttpStatus: 400
|
|
545
783
|
});
|
|
546
784
|
}
|
|
547
|
-
if (typeof data !== "object" || data === null) {
|
|
548
|
-
return buildNetworkError({
|
|
785
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
786
|
+
return ctx.buildNetworkError({
|
|
549
787
|
response: { status: "error", errorCode: "api.invalidDataObject" },
|
|
550
788
|
fallbackHttpStatus: 400
|
|
551
789
|
});
|
|
552
790
|
}
|
|
553
|
-
const
|
|
791
|
+
const normalizedName = name.startsWith("api/") ? name : `api/${name}`;
|
|
554
792
|
const parsedRoute = parseTransportRouteName2({ value: normalizedName, prefix: "api" });
|
|
555
793
|
if (parsedRoute.status === "error") {
|
|
556
|
-
return buildNetworkError({
|
|
794
|
+
return ctx.buildNetworkError({
|
|
557
795
|
response: {
|
|
558
796
|
status: "error",
|
|
559
797
|
errorCode: "routing.invalidServiceRouteName",
|
|
@@ -563,12 +801,12 @@ async function runHandleHttpApiRequestInner({
|
|
|
563
801
|
});
|
|
564
802
|
}
|
|
565
803
|
const resolvedName = parsedRoute.normalizedFullName;
|
|
566
|
-
if (
|
|
567
|
-
|
|
804
|
+
if (shouldLogDev()) {
|
|
805
|
+
getLogger9().debug(`http api: ${resolvedName} called`);
|
|
568
806
|
}
|
|
569
807
|
const { apisObject, functionsObject } = await getRuntimeApiMapsFromSource();
|
|
570
808
|
if (!apisObject[resolvedName]) {
|
|
571
|
-
return buildNetworkError({
|
|
809
|
+
return ctx.buildNetworkError({
|
|
572
810
|
response: {
|
|
573
811
|
status: "error",
|
|
574
812
|
errorCode: "api.notFound",
|
|
@@ -577,26 +815,44 @@ async function runHandleHttpApiRequestInner({
|
|
|
577
815
|
fallbackHttpStatus: 404
|
|
578
816
|
});
|
|
579
817
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
818
|
+
return { resolvedName, requestData: data, apisObject, functionsObject };
|
|
819
|
+
}
|
|
820
|
+
function runHttpApiAuth({
|
|
821
|
+
name,
|
|
822
|
+
resolvedName,
|
|
823
|
+
requesterIp,
|
|
824
|
+
auth
|
|
825
|
+
}, ctx) {
|
|
826
|
+
if (auth.login && !ctx.user?.id) {
|
|
827
|
+
if (shouldLogDev()) {
|
|
828
|
+
getLogger9().warn(`http-api: ${name} requires login`, { route: name, transport: "http" });
|
|
588
829
|
}
|
|
589
|
-
|
|
830
|
+
void dispatchHook5("apiAuthRejected", {
|
|
831
|
+
routeName: resolvedName,
|
|
832
|
+
reason: "login-required",
|
|
833
|
+
userId: null,
|
|
834
|
+
transport: "http",
|
|
835
|
+
ip: requesterIp
|
|
836
|
+
});
|
|
837
|
+
return ctx.buildNetworkError({
|
|
590
838
|
response: { status: "error", errorCode: "auth.required" },
|
|
591
839
|
fallbackHttpStatus: 401
|
|
592
840
|
});
|
|
593
841
|
}
|
|
594
|
-
const authResult = validateRequest2({ auth, user });
|
|
842
|
+
const authResult = validateRequest2({ auth, user: ctx.user });
|
|
595
843
|
if (authResult.status === "error") {
|
|
596
|
-
if (
|
|
597
|
-
|
|
844
|
+
if (shouldLogDev()) {
|
|
845
|
+
getLogger9().warn(`http-api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode, transport: "http" });
|
|
598
846
|
}
|
|
599
|
-
|
|
847
|
+
void dispatchHook5("apiAuthRejected", {
|
|
848
|
+
routeName: resolvedName,
|
|
849
|
+
reason: authResult.errorCode === "auth.misconfiguredPredicate" ? "invalid-condition" : "additional-failed",
|
|
850
|
+
userId: ctx.user?.id ?? null,
|
|
851
|
+
transport: "http",
|
|
852
|
+
ip: requesterIp,
|
|
853
|
+
failedKey: authResult.errorCode
|
|
854
|
+
});
|
|
855
|
+
return ctx.buildNetworkError({
|
|
600
856
|
response: {
|
|
601
857
|
status: "error",
|
|
602
858
|
errorCode: authResult.errorCode ?? "auth.forbidden",
|
|
@@ -606,77 +862,48 @@ async function runHandleHttpApiRequestInner({
|
|
|
606
862
|
fallbackHttpStatus: authResult.httpStatus ?? 403
|
|
607
863
|
});
|
|
608
864
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
errorCode: "api.rateLimitExceeded",
|
|
637
|
-
errorParams: [{ key: "seconds", value: resetIn }]
|
|
638
|
-
},
|
|
639
|
-
fallbackHttpStatus: 429
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const defaultIpLimit = getProjectConfig2().rateLimiting.defaultIpLimit;
|
|
644
|
-
const requesterIsLoopback = process.env.NODE_ENV !== "production" && (requesterIp === "127.0.0.1" || requesterIp === "::1" || requesterIp === "::ffff:127.0.0.1" || typeof requesterIp === "string" && requesterIp.startsWith("127."));
|
|
645
|
-
if (!requesterIsLoopback && defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
646
|
-
const ipBucket = requesterIp ?? "unknown";
|
|
647
|
-
const { allowed, resetIn } = await checkRateLimit2({
|
|
648
|
-
key: `ip:${ipBucket}:api:all`,
|
|
649
|
-
limit: defaultIpLimit,
|
|
650
|
-
windowMs: getProjectConfig2().rateLimiting.windowMs
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
async function applyHttpApiRateLimits({
|
|
868
|
+
requesterIp,
|
|
869
|
+
token,
|
|
870
|
+
resolvedName,
|
|
871
|
+
rateLimit
|
|
872
|
+
}, ctx) {
|
|
873
|
+
const effectiveIp = requesterIp ?? resolveClientIp2({ rawAddress: void 0, headers: {} });
|
|
874
|
+
const skipGlobalIpForLoopback = getProjectConfig5().rateLimiting.skipLoopbackInDev && process.env.NODE_ENV !== "production" && isLoopbackIp(requesterIp ?? "");
|
|
875
|
+
const rateLimitResult = await applyApiRateLimits({
|
|
876
|
+
resolvedIp: effectiveIp,
|
|
877
|
+
token,
|
|
878
|
+
user: ctx.user,
|
|
879
|
+
resolvedName,
|
|
880
|
+
rateLimit,
|
|
881
|
+
transport: "http",
|
|
882
|
+
skipGlobalIpBucket: skipGlobalIpForLoopback
|
|
883
|
+
});
|
|
884
|
+
if (!rateLimitResult.allowed) {
|
|
885
|
+
return ctx.buildNetworkError({
|
|
886
|
+
response: {
|
|
887
|
+
status: "error",
|
|
888
|
+
errorCode: rateLimitResult.errorCode ?? "api.rateLimitExceeded",
|
|
889
|
+
errorParams: [{ key: "seconds", value: rateLimitResult.resetIn ?? 0 }]
|
|
890
|
+
},
|
|
891
|
+
fallbackHttpStatus: 429
|
|
651
892
|
});
|
|
652
|
-
if (!allowed) {
|
|
653
|
-
void dispatchHook2("rateLimitExceeded", {
|
|
654
|
-
scope: "ip",
|
|
655
|
-
key: `ip:${ipBucket}:api:all`,
|
|
656
|
-
limit: defaultIpLimit,
|
|
657
|
-
windowMs: getProjectConfig2().rateLimiting.windowMs,
|
|
658
|
-
count: defaultIpLimit + 1,
|
|
659
|
-
ip: ipBucket
|
|
660
|
-
});
|
|
661
|
-
if (shouldLogDev2()) {
|
|
662
|
-
getLogger2().warn(`http api: global IP rate limit exceeded`, { ip: ipBucket });
|
|
663
|
-
}
|
|
664
|
-
return buildNetworkError({
|
|
665
|
-
response: {
|
|
666
|
-
status: "error",
|
|
667
|
-
errorCode: "api.rateLimitExceeded",
|
|
668
|
-
errorParams: [{ key: "seconds", value: resetIn }]
|
|
669
|
-
},
|
|
670
|
-
fallbackHttpStatus: 429
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
893
|
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
function checkHttpApiMethod({
|
|
897
|
+
method,
|
|
898
|
+
resolvedName,
|
|
899
|
+
declaredMethod
|
|
900
|
+
}, ctx) {
|
|
674
901
|
const expectedMethod = declaredMethod ?? inferHttpMethod(resolvedName);
|
|
675
902
|
if (method !== expectedMethod) {
|
|
676
|
-
if (
|
|
677
|
-
|
|
903
|
+
if (shouldLogDev()) {
|
|
904
|
+
getLogger9().warn(`http api: method mismatch for ${resolvedName}`, { expected: expectedMethod, got: method });
|
|
678
905
|
}
|
|
679
|
-
return buildNetworkError({
|
|
906
|
+
return ctx.buildNetworkError({
|
|
680
907
|
response: {
|
|
681
908
|
status: "error",
|
|
682
909
|
errorCode: "api.methodNotAllowed",
|
|
@@ -685,129 +912,178 @@ async function runHandleHttpApiRequestInner({
|
|
|
685
912
|
fallbackHttpStatus: 405
|
|
686
913
|
});
|
|
687
914
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
user,
|
|
700
|
-
validation: inputValidation,
|
|
701
|
-
transport: "http"
|
|
702
|
-
});
|
|
703
|
-
if (inputValidation.status === "error") {
|
|
704
|
-
return buildNetworkError({
|
|
705
|
-
response: {
|
|
706
|
-
status: "error",
|
|
707
|
-
errorCode: "api.invalidInputType",
|
|
708
|
-
errorParams: [{ key: "message", value: inputValidation.message }]
|
|
709
|
-
},
|
|
710
|
-
fallbackHttpStatus: 400
|
|
711
|
-
});
|
|
712
|
-
}
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
async function runHttpApiExecution({
|
|
918
|
+
resolvedName,
|
|
919
|
+
requestData,
|
|
920
|
+
functionsObject,
|
|
921
|
+
runtimeApiRoute,
|
|
922
|
+
effectiveAbortSignal,
|
|
923
|
+
stream
|
|
924
|
+
}, ctx) {
|
|
925
|
+
const flushPressure = httpApiFlushPressureNoop;
|
|
713
926
|
const emitApiStream = (payload = {}) => {
|
|
714
927
|
if (!stream) {
|
|
715
928
|
return;
|
|
716
929
|
}
|
|
717
930
|
if (effectiveAbortSignal.aborted) {
|
|
718
|
-
if (
|
|
719
|
-
|
|
931
|
+
if (shouldLogStream()) {
|
|
932
|
+
getLogger9().debug(`http api: ${resolvedName} stream skipped \u2014 request aborted`);
|
|
720
933
|
}
|
|
721
934
|
return;
|
|
722
935
|
}
|
|
723
|
-
if (
|
|
724
|
-
|
|
936
|
+
if (shouldLogStream()) {
|
|
937
|
+
getLogger9().debug(`http api: ${resolvedName} stream`, { payload });
|
|
725
938
|
}
|
|
726
939
|
stream(payload);
|
|
727
940
|
};
|
|
728
941
|
const executePayload = {
|
|
729
942
|
routeName: resolvedName,
|
|
730
943
|
data: requestData,
|
|
731
|
-
user,
|
|
944
|
+
user: ctx.user,
|
|
732
945
|
transport: "http",
|
|
733
946
|
result: void 0,
|
|
734
947
|
error: null,
|
|
735
948
|
durationMs: 0
|
|
736
949
|
};
|
|
737
|
-
const preExecuteResult = await
|
|
950
|
+
const preExecuteResult = await dispatchHook5("preApiExecute", executePayload);
|
|
738
951
|
if (preExecuteResult.stopped) {
|
|
739
|
-
return buildNetworkError({
|
|
952
|
+
return ctx.buildNetworkError({
|
|
740
953
|
response: { status: "error", errorCode: preExecuteResult.signal.errorCode },
|
|
741
954
|
fallbackHttpStatus: preExecuteResult.signal.httpStatus ?? 403
|
|
742
955
|
});
|
|
743
956
|
}
|
|
957
|
+
const timeoutMs = getProjectConfig5().api.requestTimeoutMs;
|
|
958
|
+
const combinedController = new AbortController();
|
|
959
|
+
if (effectiveAbortSignal.aborted) {
|
|
960
|
+
combinedController.abort();
|
|
961
|
+
} else {
|
|
962
|
+
effectiveAbortSignal.addEventListener("abort", () => {
|
|
963
|
+
combinedController.abort();
|
|
964
|
+
}, { once: true });
|
|
965
|
+
}
|
|
966
|
+
let timedOut = false;
|
|
967
|
+
let timeoutHandle;
|
|
968
|
+
const timeoutPromise = timeoutMs === false ? null : new Promise((resolve) => {
|
|
969
|
+
timeoutHandle = setTimeout(() => {
|
|
970
|
+
timedOut = true;
|
|
971
|
+
combinedController.abort();
|
|
972
|
+
resolve();
|
|
973
|
+
}, timeoutMs);
|
|
974
|
+
});
|
|
744
975
|
const executeStart = Date.now();
|
|
745
|
-
const
|
|
746
|
-
async () => await main({
|
|
976
|
+
const handlerPromise = tryCatch2(
|
|
977
|
+
async () => await runtimeApiRoute.main({
|
|
747
978
|
data: requestData,
|
|
748
|
-
user,
|
|
979
|
+
user: ctx.user,
|
|
749
980
|
functions: functionsObject,
|
|
750
981
|
stream: emitApiStream,
|
|
751
|
-
abortSignal:
|
|
982
|
+
abortSignal: combinedController.signal,
|
|
752
983
|
flushPressure
|
|
753
984
|
}),
|
|
754
985
|
void 0,
|
|
755
986
|
{
|
|
756
987
|
handler: "handleHttpApiRequest",
|
|
757
988
|
api: resolvedName,
|
|
758
|
-
userId: user?.id,
|
|
989
|
+
userId: ctx.user?.id,
|
|
759
990
|
transport: "http"
|
|
760
991
|
}
|
|
761
992
|
);
|
|
993
|
+
if (timeoutPromise) {
|
|
994
|
+
await Promise.race([handlerPromise, timeoutPromise]);
|
|
995
|
+
}
|
|
996
|
+
clearTimeout(timeoutHandle);
|
|
997
|
+
if (timedOut) {
|
|
998
|
+
return ctx.buildNetworkError({
|
|
999
|
+
response: { status: "error", errorCode: "api.timeout" },
|
|
1000
|
+
fallbackHttpStatus: 504
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const [error, result] = await handlerPromise;
|
|
762
1004
|
executePayload.result = result;
|
|
763
1005
|
executePayload.error = error ?? null;
|
|
764
1006
|
executePayload.durationMs = Date.now() - executeStart;
|
|
765
|
-
await
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1007
|
+
await dispatchHook5("postApiExecute", executePayload);
|
|
1008
|
+
const envelope = normalizeApiResponse({
|
|
1009
|
+
resolvedName,
|
|
1010
|
+
error: error ?? null,
|
|
1011
|
+
result: result ?? void 0,
|
|
1012
|
+
preferredLocale: ctx.preferredLocale,
|
|
1013
|
+
user: ctx.user
|
|
1014
|
+
});
|
|
1015
|
+
return envelope;
|
|
1016
|
+
}
|
|
1017
|
+
async function runHandleHttpApiRequestInner({
|
|
1018
|
+
name,
|
|
1019
|
+
data,
|
|
1020
|
+
token,
|
|
1021
|
+
requesterIp,
|
|
1022
|
+
xLanguageHeader: _xLanguageHeader,
|
|
1023
|
+
acceptLanguageHeader: _acceptLanguageHeader,
|
|
1024
|
+
method = "POST",
|
|
1025
|
+
stream,
|
|
1026
|
+
abortSignal
|
|
1027
|
+
}, user, preferredLocale, formatterHolder) {
|
|
1028
|
+
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
|
|
1029
|
+
const buildNetworkError = ({
|
|
1030
|
+
response,
|
|
1031
|
+
fallbackHttpStatus
|
|
1032
|
+
}) => {
|
|
1033
|
+
return normalizeErrorResponse3({
|
|
1034
|
+
response,
|
|
1035
|
+
preferredLocale,
|
|
1036
|
+
userLanguage: user?.language,
|
|
1037
|
+
fallbackHttpStatus
|
|
773
1038
|
});
|
|
1039
|
+
};
|
|
1040
|
+
const ctx = { buildNetworkError, user, preferredLocale };
|
|
1041
|
+
const shapeResult = await validateHttpApiRequestShape(name, data, ctx);
|
|
1042
|
+
if ("status" in shapeResult) {
|
|
1043
|
+
return shapeResult;
|
|
774
1044
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
const successEnvelope = {
|
|
790
|
-
...result,
|
|
791
|
-
status: "success",
|
|
792
|
-
httpStatus: defaultHttpStatusForResponse2({
|
|
793
|
-
status: "success",
|
|
794
|
-
explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
|
|
795
|
-
})
|
|
796
|
-
};
|
|
797
|
-
return successEnvelope;
|
|
798
|
-
}
|
|
799
|
-
return buildNetworkError({
|
|
800
|
-
response: { status: "error", errorCode: "api.invalidResponseStatus" },
|
|
801
|
-
fallbackHttpStatus: 500
|
|
802
|
-
});
|
|
1045
|
+
const { resolvedName, requestData, apisObject, functionsObject } = shapeResult;
|
|
1046
|
+
const runtimeApiRoute = apisObject[resolvedName];
|
|
1047
|
+
formatterHolder.current = runtimeApiRoute.errorFormatter;
|
|
1048
|
+
const authError = runHttpApiAuth({ name, resolvedName, requesterIp, auth: runtimeApiRoute.auth }, ctx);
|
|
1049
|
+
if (authError) {
|
|
1050
|
+
return authError;
|
|
1051
|
+
}
|
|
1052
|
+
const rateLimitError = await applyHttpApiRateLimits(
|
|
1053
|
+
{ requesterIp, token, resolvedName, rateLimit: runtimeApiRoute.rateLimit },
|
|
1054
|
+
ctx
|
|
1055
|
+
);
|
|
1056
|
+
if (rateLimitError) {
|
|
1057
|
+
return rateLimitError;
|
|
803
1058
|
}
|
|
804
|
-
|
|
805
|
-
|
|
1059
|
+
const methodError = checkHttpApiMethod(
|
|
1060
|
+
{ method, resolvedName, declaredMethod: runtimeApiRoute.httpMethod },
|
|
1061
|
+
ctx
|
|
1062
|
+
);
|
|
1063
|
+
if (methodError) {
|
|
1064
|
+
return methodError;
|
|
806
1065
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1066
|
+
const httpValidation = await runHttpApiValidation({
|
|
1067
|
+
resolvedName,
|
|
1068
|
+
inputType: runtimeApiRoute.inputType,
|
|
1069
|
+
inputTypeFilePath: runtimeApiRoute.inputTypeFilePath,
|
|
1070
|
+
requestData,
|
|
1071
|
+
user,
|
|
1072
|
+
validation: runtimeApiRoute.validation
|
|
810
1073
|
});
|
|
1074
|
+
if (!httpValidation.ok) {
|
|
1075
|
+
return buildNetworkError({
|
|
1076
|
+
response: {
|
|
1077
|
+
status: "error",
|
|
1078
|
+
errorCode: "api.invalidInputType"
|
|
1079
|
+
},
|
|
1080
|
+
fallbackHttpStatus: 400
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
return runHttpApiExecution(
|
|
1084
|
+
{ resolvedName, requestData, functionsObject, runtimeApiRoute, effectiveAbortSignal, stream },
|
|
1085
|
+
ctx
|
|
1086
|
+
);
|
|
811
1087
|
}
|
|
812
1088
|
export {
|
|
813
1089
|
handleApiRequest,
|