@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/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
- buildApiStreamEventName,
12
- dispatchHook,
13
- validateInputByType,
14
- getLogger
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
- defaultHttpStatusForResponse,
18
- extractLanguageFromHeader,
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
- var shouldLogDev = () => getProjectConfig().logging.devLogs;
26
- var shouldLogStream = () => getProjectConfig().logging.stream;
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 (!getProjectConfig().dev.warnOnMissingInputType) return;
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
- getLogger().warn(`api: route ${resolvedName} has no inputType \u2014 runtime input validation is disabled. Regenerate types or set the inputType on the handler.`);
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 (!responseIndex && typeof responseIndex !== "number") {
303
+ if (typeof responseIndex !== "number") {
38
304
  if (shouldLogDev()) {
39
- getLogger().warn("api: no response index given");
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
- getLogger().warn(`api: ${name} requires login`, { route: name });
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
- getLogger().warn(`api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode });
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 applyApiRateLimits = async ({ apiEntry, resolvedName, token, socket, user, emitApiError }) => {
82
- const apiRateLimit = apiEntry.rateLimit;
83
- const effectiveApiLimit = apiRateLimit === void 0 ? getProjectConfig().rateLimiting.defaultApiLimit : apiRateLimit;
84
- if (effectiveApiLimit !== false && effectiveApiLimit > 0) {
85
- const requesterIdentity = token ?? socket.handshake.address ?? "unknown";
86
- const keyPrefix = token ? "token" : "ip";
87
- const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;
88
- const { allowed, resetIn } = await checkRateLimit({
89
- key: rateLimitKey,
90
- limit: effectiveApiLimit,
91
- windowMs: getProjectConfig().rateLimiting.windowMs
92
- });
93
- if (!allowed) {
94
- void dispatchHook("rateLimitExceeded", {
95
- scope: token ? "user" : "route",
96
- key: rateLimitKey,
97
- limit: effectiveApiLimit,
98
- windowMs: getProjectConfig().rateLimiting.windowMs,
99
- count: effectiveApiLimit + 1,
100
- route: resolvedName,
101
- userId: user?.id
102
- });
103
- if (shouldLogDev()) {
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
- if (!allowed) {
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 [error, result] = await tryCatch(
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 buildApiResponseEnvelope = ({
421
+ var runSocketApiExecution = async ({
422
+ apiEntry,
165
423
  resolvedName,
166
- error,
167
- result,
168
- preferredLocale,
169
- user
424
+ normalizedData,
425
+ user,
426
+ functionsObject,
427
+ emitStream,
428
+ abortSignal,
429
+ abortController,
430
+ flushPressure
170
431
  }) => {
171
- if (error) {
172
- if (shouldLogDev()) {
173
- getLogger().error(`api: error in ${resolvedName}`, error, { route: resolvedName });
174
- }
175
- return { ...normalizeErrorResponse({
176
- response: { status: "error", errorCode: "api.internalServerError" },
177
- preferredLocale,
178
- userLanguage: user?.language,
179
- fallbackHttpStatus: 500
180
- }) };
181
- }
182
- if (result === void 0 || result === null) {
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
- if (result.status === "error") {
205
- return { ...normalizeErrorResponse({
206
- response: result,
207
- preferredLocale,
208
- userLanguage: user?.language,
209
- fallbackHttpStatus: defaultHttpStatusForResponse({
210
- status: "error",
211
- explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
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
- return {
216
- ...result,
217
- status: "success",
218
- httpStatus: defaultHttpStatusForResponse({
219
- status: "success",
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 = buildApiResponseEnvelope({ resolvedName, error, result, preferredLocale, user });
475
+ const envelope = normalizeApiResponse({ resolvedName, error, result, preferredLocale, user });
235
476
  const preRespond = { routeName: resolvedName, user, response: envelope };
236
- const preRespondResult = await dispatchHook("preApiRespond", preRespond);
237
- const finalResponse = preRespondResult.stopped ? { ...normalizeErrorResponse({
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 dispatchHook("transformApiResponse", transformPayload);
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 dispatchHook("postApiRespond", { routeName: resolvedName, user, response: formattedResponse });
494
+ await dispatchHook3("postApiRespond", { routeName: resolvedName, user, response: formattedResponse });
254
495
  };
255
- async function handleApiRequest({ msg, socket, token }) {
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
- getLogger().warn("api: socket message was not a json object");
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 user = await getSession(token);
264
- const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]) || void 0;
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 = normalizeErrorResponse({
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
- return emitApiError({
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
- await logout({ token, socket, userId: user?.id || null });
301
- return socket.emit(buildApiResponseEventName(responseIndex), {
302
- status: "success",
303
- httpStatus: 200,
304
- result: true
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
- getLogger().debug(`api: ${resolvedName} called`);
608
+ getLogger7().debug(`api: ${resolvedName} called`);
309
609
  }
310
610
  const { apisObject, functionsObject } = await getRuntimeApiMaps();
311
611
  if (!apisObject[resolvedName]) {
312
- return emitApiError({
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 executePayload = {
416
- routeName: resolvedName,
417
- data: normalizedData,
418
- user,
419
- transport: "socket",
420
- result: void 0,
421
- error: null,
422
- durationMs: 0
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 emitApiError({
428
- response: { status: "error", errorCode: preExecuteResult.signal.errorCode },
429
- fallbackHttpStatus: preExecuteResult.signal.httpStatus ?? 403
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 { error, result, durationMs } = await executeApiHandler({
638
+ const validationOk = await runSocketApiValidation({
433
639
  apiEntry,
640
+ resolvedName,
434
641
  normalizedData,
435
642
  user,
436
- functionsObject,
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: abortController.signal,
440
- flushPressure: apiFlushPressure
659
+ abortSignal,
660
+ abortController,
661
+ flushPressure
441
662
  });
442
- executePayload.result = result;
443
- executePayload.error = error ?? null;
444
- executePayload.durationMs = durationMs;
445
- await dispatchHook("postApiExecute", executePayload);
446
- await emitApiResult({ socket, responseIndex, resolvedName, error, result, preferredLocale, user, perRouteFormatter: apiEntry.errorFormatter });
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 getProjectConfig2,
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
- validateInputByType as validateInputByType2,
461
- dispatchHook as dispatchHook2,
462
- getLogger as getLogger2,
463
- defaultHttpStatusForResponse as defaultHttpStatusForResponse2,
686
+ dispatchHook as dispatchHook5,
687
+ getLogger as getLogger9,
464
688
  extractLanguageFromHeader as extractLanguageFromHeader2,
465
- normalizeErrorResponse as normalizeErrorResponse2,
466
- applyErrorFormatter as applyErrorFormatter2
689
+ normalizeErrorResponse as normalizeErrorResponse3,
690
+ applyErrorFormatter as applyErrorFormatter2,
691
+ isLoopbackIp,
692
+ resolveClientIp as resolveClientIp2
467
693
  } from "@luckystack/core";
468
- var httpApiFlushPressureNoop = async () => {
469
- };
470
- var isRuntimeApiResult = (value) => {
471
- if (!value || typeof value !== "object") {
472
- return false;
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
- const status = value.status;
475
- return status === "success" || status === "error";
476
- };
477
- var shouldLogDev2 = () => getProjectConfig2().logging.devLogs;
478
- var shouldLogStream2 = () => getProjectConfig2().logging.stream;
479
- var warnedMissingInputType2 = /* @__PURE__ */ new Set();
480
- var warnIfInputTypeMissing2 = (resolvedName, inputType) => {
481
- if (!getProjectConfig2().dev.warnOnMissingInputType) return;
482
- if (inputType && inputType.trim().length > 0 && inputType.trim() !== "any") return;
483
- if (warnedMissingInputType2.has(resolvedName)) return;
484
- warnedMissingInputType2.add(resolvedName);
485
- getLogger2().warn(`http-api: route ${resolvedName} has no inputType \u2014 runtime input validation is disabled.`);
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 dispatchHook2("preApiRespond", preRespond);
492
- const finalResponse = preRespondResult.stopped ? normalizeErrorResponse2({
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 formatterInput = finalResponse;
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 dispatchHook2("postApiRespond", { routeName: routeNameForHook, user, response: formattedResponse });
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 getSession2(params.token);
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 runHandleHttpApiRequestInner({
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 requestData = data ?? {};
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 (shouldLogDev2()) {
567
- getLogger2().debug(`http api: ${resolvedName} called`);
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
- const runtimeApiRoute = apisObject[resolvedName];
581
- formatterHolder.current = runtimeApiRoute.errorFormatter;
582
- const { auth, main, httpMethod: declaredMethod } = runtimeApiRoute;
583
- const inputType = runtimeApiRoute.inputType;
584
- const inputTypeFilePath = runtimeApiRoute.inputTypeFilePath;
585
- if (auth.login && !user?.id) {
586
- if (shouldLogDev2()) {
587
- getLogger2().warn(`http-api: ${name} requires login`, { route: name, transport: "http" });
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
- return buildNetworkError({
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 (shouldLogDev2()) {
597
- getLogger2().warn(`http-api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode, transport: "http" });
844
+ if (shouldLogDev()) {
845
+ getLogger9().warn(`http-api: auth failed for ${name}`, { route: name, errorCode: authResult.errorCode, transport: "http" });
598
846
  }
599
- return buildNetworkError({
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
- const apiRateLimit = runtimeApiRoute.rateLimit;
610
- const effectiveApiLimit = apiRateLimit === void 0 ? getProjectConfig2().rateLimiting.defaultApiLimit : apiRateLimit;
611
- if (effectiveApiLimit !== false && effectiveApiLimit > 0) {
612
- const requesterIdentity = token ?? requesterIp ?? "anonymous";
613
- const keyPrefix = token ? "token" : "ip";
614
- const rateLimitKey = `${keyPrefix}:${requesterIdentity}:api:${resolvedName}`;
615
- const { allowed, resetIn } = await checkRateLimit2({
616
- key: rateLimitKey,
617
- limit: effectiveApiLimit,
618
- windowMs: getProjectConfig2().rateLimiting.windowMs
619
- });
620
- if (!allowed) {
621
- void dispatchHook2("rateLimitExceeded", {
622
- scope: token ? "user" : "route",
623
- key: rateLimitKey,
624
- limit: effectiveApiLimit,
625
- windowMs: getProjectConfig2().rateLimiting.windowMs,
626
- count: effectiveApiLimit + 1,
627
- route: resolvedName,
628
- userId: user?.id
629
- });
630
- if (shouldLogDev2()) {
631
- getLogger2().warn(`http api: rate limit exceeded for ${resolvedName}`);
632
- }
633
- return buildNetworkError({
634
- response: {
635
- status: "error",
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 (shouldLogDev2()) {
677
- getLogger2().warn(`http api: method mismatch for ${resolvedName}`, { expected: expectedMethod, got: method });
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
- warnIfInputTypeMissing2(resolvedName, inputType);
689
- await dispatchHook2("preApiValidate", { routeName: resolvedName, data: requestData, user, transport: "http" });
690
- const inputValidation = await validateInputByType2({
691
- typeText: inputType,
692
- value: requestData,
693
- rootKey: "data",
694
- filePath: inputTypeFilePath
695
- });
696
- await dispatchHook2("postApiValidate", {
697
- routeName: resolvedName,
698
- data: requestData,
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 (shouldLogStream2()) {
719
- getLogger2().debug(`http api: ${resolvedName} stream skipped \u2014 request aborted`);
931
+ if (shouldLogStream()) {
932
+ getLogger9().debug(`http api: ${resolvedName} stream skipped \u2014 request aborted`);
720
933
  }
721
934
  return;
722
935
  }
723
- if (shouldLogStream2()) {
724
- getLogger2().debug(`http api: ${resolvedName} stream`, { payload });
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 dispatchHook2("preApiExecute", executePayload);
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 [error, result] = await tryCatch2(
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: effectiveAbortSignal,
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 dispatchHook2("postApiExecute", executePayload);
766
- if (error) {
767
- if (shouldLogDev2()) {
768
- getLogger2().error(`http-api: error in ${resolvedName}`, error, { route: resolvedName, transport: "http" });
769
- }
770
- return buildNetworkError({
771
- response: { status: "error", errorCode: "api.internalServerError" },
772
- fallbackHttpStatus: 500
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
- if (result !== void 0 && result !== null) {
776
- if (shouldLogDev2()) {
777
- getLogger2().debug(`http api: ${resolvedName} completed`);
778
- }
779
- if (isRuntimeApiResult(result)) {
780
- if (result.status === "error") {
781
- return buildNetworkError({
782
- response: result,
783
- fallbackHttpStatus: defaultHttpStatusForResponse2({
784
- status: "error",
785
- explicitHttpStatus: typeof result.httpStatus === "number" ? result.httpStatus : void 0
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
- if (shouldLogDev2()) {
805
- getLogger2().warn(`http api: ${resolvedName} returned nothing`);
1059
+ const methodError = checkHttpApiMethod(
1060
+ { method, resolvedName, declaredMethod: runtimeApiRoute.httpMethod },
1061
+ ctx
1062
+ );
1063
+ if (methodError) {
1064
+ return methodError;
806
1065
  }
807
- return buildNetworkError({
808
- response: { status: "error", errorCode: "api.emptyResponse" },
809
- fallbackHttpStatus: 500
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,