@luckystack/server 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,6 +1,13 @@
1
+ import {
2
+ applyServerArgv,
3
+ getParsedBundles,
4
+ getParsedPort,
5
+ parseServerArgv
6
+ } from "./chunk-X3OSC5W3.js";
7
+
1
8
  // src/createServer.ts
2
9
  import http from "http";
3
- import { registerBindAddress, writeBootUuid, getLogger as getLogger9, getProjectConfig as getProjectConfig10, tryCatch as tryCatch7 } from "@luckystack/core";
10
+ import { registerBindAddress, writeBootUuid, getLogger as getLogger12, getProjectConfig as getProjectConfig13, tryCatch as tryCatch9, isProduction as isProduction2 } from "@luckystack/core";
4
11
 
5
12
  // src/httpHandler.ts
6
13
  import { randomUUID } from "crypto";
@@ -8,40 +15,139 @@ import {
8
15
  allowedOrigin,
9
16
  dispatchHook as dispatchHook5,
10
17
  extractTokenFromRequest as extractTokenFromRequest3,
11
- getLogger as getLogger6,
18
+ getLogger as getLogger7,
12
19
  getParams,
13
- getProjectConfig as getProjectConfig8,
14
- hasCookie
20
+ getProjectConfig as getProjectConfig10,
21
+ hasCookie,
22
+ readSession as readSession3,
23
+ tryCatchSync
15
24
  } from "@luckystack/core";
16
- import { getSession as getSession3 } from "@luckystack/login";
17
25
 
18
26
  // src/logSanitize.ts
19
27
  import { getProjectConfig, isRedactedLogKey } from "@luckystack/core";
20
28
  var REDACTED_PLACEHOLDER = "[REDACTED]";
29
+ var TRUNCATED_PLACEHOLDER = "[TRUNCATED]";
30
+ var MAX_SANITIZE_DEPTH = 40;
21
31
  var isRedactedKey = (key) => {
22
32
  if (isRedactedLogKey(key)) return true;
23
33
  return key.toLowerCase() === getProjectConfig().http.sessionCookieName.toLowerCase();
24
34
  };
25
- var sanitizeForLog = (value) => {
35
+ var sanitizeWithGuards = (value, depth, seen) => {
26
36
  if (value === null || typeof value !== "object") return value;
27
- if (Array.isArray(value)) return value.map((entry) => sanitizeForLog(entry));
28
- const out = {};
29
- for (const [key, val] of Object.entries(value)) {
30
- out[key] = isRedactedKey(key) ? REDACTED_PLACEHOLDER : sanitizeForLog(val);
37
+ if (seen.has(value)) return TRUNCATED_PLACEHOLDER;
38
+ if (depth >= MAX_SANITIZE_DEPTH) return TRUNCATED_PLACEHOLDER;
39
+ seen.add(value);
40
+ try {
41
+ if (Array.isArray(value)) {
42
+ return value.map((entry) => sanitizeWithGuards(entry, depth + 1, seen));
43
+ }
44
+ const out = {};
45
+ for (const [key, val] of Object.entries(value)) {
46
+ out[key] = isRedactedKey(key) ? REDACTED_PLACEHOLDER : sanitizeWithGuards(val, depth + 1, seen);
47
+ }
48
+ return out;
49
+ } finally {
50
+ seen.delete(value);
31
51
  }
32
- return out;
33
52
  };
53
+ var sanitizeForLog = (value) => sanitizeWithGuards(value, 0, /* @__PURE__ */ new WeakSet());
34
54
 
35
55
  // src/securityHeadersRegistry.ts
36
- var registeredBuilder = null;
56
+ import { createRegistry } from "@luckystack/core";
57
+ var builderRegistry = createRegistry(null);
37
58
  var registerSecurityHeaders = (builder) => {
38
- registeredBuilder = builder;
59
+ builderRegistry.register(builder);
60
+ };
61
+ var getSecurityHeadersBuilder = () => builderRegistry.get();
62
+
63
+ // src/httpRoutes/csrfMiddleware.ts
64
+ import { dispatchHook, getCookieValue, getCsrfConfig, getProjectConfig as getProjectConfig2, readSession } from "@luckystack/core";
65
+
66
+ // src/capabilities.ts
67
+ import { createRequire } from "module";
68
+ var localRequire = createRequire(import.meta.url);
69
+ var esmResolve = import.meta.resolve;
70
+ var _warnedResolverMissing = false;
71
+ var warnResolverMissingOnce = () => {
72
+ if (_warnedResolverMissing) return;
73
+ _warnedResolverMissing = true;
74
+ console.warn(
75
+ "[luckystack:capabilities] import.meta.resolve is unavailable (Node < 20.6). Optional package detection falls back to require.resolve, which cannot resolve import-only exports maps and may misreport @luckystack/* packages as absent. Upgrade to Node >= 20.6 to ensure correct capability detection."
76
+ );
77
+ };
78
+ var has = (pkg) => {
79
+ if (typeof esmResolve === "function") {
80
+ try {
81
+ esmResolve(pkg);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+ warnResolverMissingOnce();
88
+ try {
89
+ localRequire.resolve(pkg);
90
+ return true;
91
+ } catch (error) {
92
+ if (error instanceof Error && error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ };
98
+ var capabilities = {
99
+ login: has("@luckystack/login"),
100
+ presence: has("@luckystack/presence"),
101
+ sync: has("@luckystack/sync")
102
+ };
103
+ var OPTIONAL_PACKAGES = [
104
+ "login",
105
+ "email",
106
+ "error-tracking",
107
+ "presence",
108
+ "docs-ui"
109
+ ];
110
+ var canResolve = (specifier) => has(specifier);
111
+ var loginMod;
112
+ var getLogin = async () => {
113
+ if (loginMod !== void 0) return loginMod;
114
+ loginMod = capabilities.login ? await import("@luckystack/login") : null;
115
+ return loginMod;
116
+ };
117
+ var presenceMod;
118
+ var getPresence = async () => {
119
+ if (presenceMod !== void 0) return presenceMod;
120
+ presenceMod = capabilities.presence ? await import("@luckystack/presence") : null;
121
+ return presenceMod;
122
+ };
123
+ var syncMod;
124
+ var getSync = async () => {
125
+ if (syncMod !== void 0) return syncMod;
126
+ syncMod = capabilities.sync ? await import("@luckystack/sync") : null;
127
+ return syncMod;
128
+ };
129
+
130
+ // src/originExemptRegistry.ts
131
+ var exemptPaths = [];
132
+ var registerOriginExemptPath = (matcher) => {
133
+ exemptPaths.push(matcher);
134
+ };
135
+ var getOriginExemptPaths = () => exemptPaths;
136
+ var clearOriginExemptPaths = () => {
137
+ exemptPaths.length = 0;
138
+ };
139
+ var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
140
+
141
+ // src/httpRoutes/timingSafeEqual.ts
142
+ import { timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
143
+ var timingSafeStringEqual = (a, b) => {
144
+ const aBuf = Buffer.from(a, "utf8");
145
+ const bBuf = Buffer.from(b, "utf8");
146
+ if (aBuf.length !== bBuf.length) return false;
147
+ return cryptoTimingSafeEqual(aBuf, bBuf);
39
148
  };
40
- var getSecurityHeadersBuilder = () => registeredBuilder;
41
149
 
42
150
  // src/httpRoutes/csrfMiddleware.ts
43
- import { dispatchHook, getCsrfConfig, getProjectConfig as getProjectConfig2 } from "@luckystack/core";
44
- import { getSession } from "@luckystack/login";
45
151
  var enforceCsrfOnStateChangingRequest = async ({
46
152
  req,
47
153
  res,
@@ -51,20 +157,41 @@ var enforceCsrfOnStateChangingRequest = async ({
51
157
  }) => {
52
158
  const config = getProjectConfig2();
53
159
  const isCookieMode = !config.session.basedToken;
54
- const isStateChanging = req.method !== "GET" && req.method !== "OPTIONS";
160
+ const isStateChanging = req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS";
55
161
  const isCallbackPath = routePath.startsWith("/auth/callback");
56
162
  const isAuthBootstrap = routePath === "/auth/api/credentials";
57
- const looksLikeFrameworkRoute = routePath.startsWith("/api/") || routePath.startsWith("/sync/") || routePath.startsWith("/auth/api/") && !isAuthBootstrap;
58
- if (!(isCookieMode && isStateChanging && looksLikeFrameworkRoute && !isCallbackPath && token)) {
163
+ const isExemptFromCsrf = isAuthBootstrap || isCallbackPath || isOriginExemptPath(routePath);
164
+ const isCsrfCandidate = routePath.startsWith("/api/") || routePath.startsWith("/sync/") || routePath.startsWith("/auth/api/") || !routePath.startsWith("/auth/") && !routePath.startsWith("/assets/");
165
+ if (!(isCookieMode && isStateChanging && isCsrfCandidate && !isExemptFromCsrf)) {
59
166
  return false;
60
167
  }
61
- const csrfSession = await getSession(token);
62
- if (!csrfSession?.id) return false;
63
168
  const csrfConfig = getCsrfConfig();
64
169
  const headerKey = csrfConfig.headerName.toLowerCase();
65
170
  const headerValue = req.headers[headerKey];
66
171
  const provided = Array.isArray(headerValue) ? headerValue[0] : headerValue;
67
- if (provided && provided === csrfSession.csrfToken) return false;
172
+ if (!capabilities.login) {
173
+ const cookieValue = getCookieValue(req.headers.cookie, csrfConfig.cookieName);
174
+ if (cookieValue && provided && timingSafeStringEqual(provided, cookieValue)) return false;
175
+ void dispatchHook("csrfMismatch", {
176
+ route: routePath,
177
+ method: req.method,
178
+ requestId,
179
+ userId: void 0,
180
+ providedToken: Boolean(provided)
181
+ });
182
+ res.statusCode = 403;
183
+ res.setHeader("Content-Type", "application/json");
184
+ res.end(JSON.stringify({
185
+ status: "error",
186
+ errorCode: "auth.csrfMismatch",
187
+ message: "CSRF token missing or invalid. Fetch /auth/csrf first."
188
+ }));
189
+ return true;
190
+ }
191
+ if (!token) return false;
192
+ const csrfSession = await readSession(token);
193
+ if (!csrfSession?.id) return false;
194
+ if (provided && csrfSession.csrfToken && timingSafeStringEqual(provided, csrfSession.csrfToken)) return false;
68
195
  void dispatchHook("csrfMismatch", {
69
196
  route: routePath,
70
197
  method: req.method,
@@ -83,16 +210,35 @@ var enforceCsrfOnStateChangingRequest = async ({
83
210
  };
84
211
 
85
212
  // src/httpRoutes/csrfRoute.ts
86
- import { getSession as getSession2 } from "@luckystack/login";
213
+ import { randomBytes } from "crypto";
214
+ import { getCsrfConfig as getCsrfConfig2, readSession as readSession2 } from "@luckystack/core";
215
+ var serializeCsrfCookie = (name, value, opts) => {
216
+ const parts = [`${name}=${value}`];
217
+ if (opts.httpOnly) parts.push("HttpOnly");
218
+ if (opts.sameSite) parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);
219
+ if (opts.secure) parts.push("Secure");
220
+ parts.push(`Path=${opts.path ?? "/"}`);
221
+ if (typeof opts.maxAgeMs === "number") parts.push(`Max-Age=${String(Math.floor(opts.maxAgeMs / 1e3))}`);
222
+ return parts.join("; ");
223
+ };
87
224
  var handleCsrfRoute = async ({ res, routePath, token }) => {
88
225
  if (routePath !== "/auth/csrf") return false;
226
+ const csrfConfig = getCsrfConfig2();
227
+ if (!capabilities.login) {
228
+ const doubleSubmit = randomBytes(csrfConfig.tokenLength).toString("hex");
229
+ res.statusCode = 200;
230
+ res.setHeader("Set-Cookie", serializeCsrfCookie(csrfConfig.cookieName, doubleSubmit, csrfConfig.cookieOptions));
231
+ res.setHeader("Content-Type", "application/json");
232
+ res.end(JSON.stringify({ status: "success", csrfToken: doubleSubmit }));
233
+ return true;
234
+ }
89
235
  if (!token) {
90
236
  res.statusCode = 401;
91
237
  res.setHeader("Content-Type", "application/json");
92
238
  res.end(JSON.stringify({ status: "error", errorCode: "auth.unauthenticated" }));
93
239
  return true;
94
240
  }
95
- const csrfSession = await getSession2(token);
241
+ const csrfSession = await readSession2(token);
96
242
  if (!csrfSession?.id) {
97
243
  res.statusCode = 401;
98
244
  res.setHeader("Content-Type", "application/json");
@@ -123,6 +269,7 @@ var handleFaviconRoute = async ({ res, routePath, options }) => {
123
269
  // src/httpRoutes/healthRoutes.ts
124
270
  import {
125
271
  computeSynchronizedEnvHashes,
272
+ describeHealthHashConfig,
126
273
  getProjectConfig as getProjectConfig3,
127
274
  prisma,
128
275
  readBootUuid,
@@ -169,14 +316,18 @@ var handleReadyzRoute = async ({ res, routePath }) => {
169
316
  var handleHealthRoute = async ({ res, routePath }) => {
170
317
  if (routePath !== getProjectConfig3().http.healthEndpoint) return false;
171
318
  const bootUuid = await readBootUuid();
172
- const synchronizedHashes = computeSynchronizedEnvHashes();
319
+ const synchronizedHashes = computeSynchronizedEnvHashes(bootUuid);
173
320
  res.statusCode = bootUuid ? 200 : 503;
174
321
  res.setHeader("Content-Type", "application/json");
175
322
  res.end(JSON.stringify({
176
323
  status: bootUuid ? "ok" : "degraded",
177
324
  bootUuid,
178
325
  envKey: resolveEnvKey(),
179
- synchronizedHashes
326
+ synchronizedHashes,
327
+ //? Tell the router HOW these hashes were produced (mode + whether the salt is
328
+ //? the `@bootUuid` sentinel) so it can hash its local values with the SAME
329
+ //? config instead of its own default. Never exposes a static salt (a secret).
330
+ healthHash: describeHealthHashConfig()
180
331
  }));
181
332
  return true;
182
333
  };
@@ -200,7 +351,9 @@ var handleTestResetRoute = async ({ req, res, routePath }) => {
200
351
  return true;
201
352
  }
202
353
  const requiredToken = process.env.TEST_RESET_TOKEN;
203
- if (!requiredToken || req.headers["x-test-reset-token"] !== requiredToken) {
354
+ const providedToken = req.headers["x-test-reset-token"];
355
+ const tokenValue = Array.isArray(providedToken) ? providedToken[0] : providedToken;
356
+ if (!requiredToken || !tokenValue || !timingSafeStringEqual(tokenValue, requiredToken)) {
204
357
  res.statusCode = 403;
205
358
  res.setHeader("Content-Type", "application/json");
206
359
  res.end(JSON.stringify({ status: "error", errorCode: "auth.forbidden" }));
@@ -232,8 +385,7 @@ var handleTestResetRoute = async ({ req, res, routePath }) => {
232
385
  await scanAndDelete(sessionPattern, "sessions");
233
386
  await scanAndDelete(activeUsersPattern, "activeUsers");
234
387
  const rawUrl = req.url ?? "/";
235
- const base = `http://${req.headers.host ?? "localhost"}`;
236
- const includeFlag = URL.canParse(rawUrl, base) ? new URL(rawUrl, base).searchParams.get("include") ?? "" : "";
388
+ const includeFlag = URL.canParse(rawUrl, "http://localhost") ? new URL(rawUrl, "http://localhost").searchParams.get("include") ?? "" : "";
237
389
  if (includeFlag.split(",").map((s) => s.trim()).includes("hooks")) {
238
390
  clearAllHooks();
239
391
  cleared.push("hooks");
@@ -257,15 +409,14 @@ import {
257
409
  checkRateLimit,
258
410
  dispatchHook as dispatchHook2,
259
411
  getLogger,
260
- getProjectConfig as getProjectConfig5
412
+ getProjectConfig as getProjectConfig5,
413
+ resolveClientIp
261
414
  } from "@luckystack/core";
262
- import {
263
- createOAuthState,
264
- deleteSession,
265
- getOAuthProviders,
266
- isFullOAuthProvider,
267
- loginWithCredentials
268
- } from "@luckystack/login";
415
+
416
+ // src/httpRoutes/sessionCookie.ts
417
+ var resolveCookieSecure = (sessionCookieSecure, secureEnv) => sessionCookieSecure ?? secureEnv === "true";
418
+
419
+ // src/httpRoutes/authApiRoute.ts
269
420
  var parseSessionBasedTokenHeader = (headerValue) => {
270
421
  if (headerValue === void 0) return null;
271
422
  const value = Array.isArray(headerValue) ? headerValue[0] : headerValue;
@@ -282,18 +433,26 @@ var handleAuthApiRoute = async ({
282
433
  sessionCookieOptions
283
434
  }) => {
284
435
  if (!routePath.startsWith("/auth/api")) return false;
436
+ const login = await getLogin();
437
+ if (!login) {
438
+ res.setHeader("content-type", "application/json; charset=utf-8");
439
+ res.end(JSON.stringify({ status: false, reason: "auth.disabled" }));
440
+ return true;
441
+ }
285
442
  const config = getProjectConfig5();
286
443
  const sessionCookieName = config.http.sessionCookieName;
287
444
  const shouldLogDev = config.logging.devLogs;
288
445
  const providerName = routePath.split("/")[3];
289
- const provider = getOAuthProviders().find((p) => p.name === providerName);
446
+ const provider = login.getOAuthProviders().find((p) => p.name === providerName);
290
447
  if (!provider?.name) {
291
448
  res.setHeader("Content-Type", "application/json");
292
449
  res.end(JSON.stringify({ status: false, reason: "login.providerNotFound" }));
293
450
  return true;
294
451
  }
295
- if (isFullOAuthProvider(provider)) {
296
- const oauthState = await createOAuthState(provider.name);
452
+ if (login.isFullOAuthProvider(provider)) {
453
+ const reqUrl = new URL(req.url ?? "/", "http://placeholder");
454
+ const returnUrl = reqUrl.searchParams.get("return_url") ?? void 0;
455
+ const oauthState = await login.createOAuthState(provider.name, { usePkce: provider.usePkce, returnUrl });
297
456
  if (!oauthState) {
298
457
  res.writeHead(500, { "Content-Type": "application/json" });
299
458
  res.end(JSON.stringify({ status: false, reason: "login.oauthStateInitFailed" }));
@@ -302,28 +461,42 @@ var handleAuthApiRoute = async ({
302
461
  const clientId = encodeURIComponent(provider.clientID);
303
462
  const callbackUrl = encodeURIComponent(provider.callbackURL);
304
463
  const scope = encodeURIComponent(provider.scope.join(" "));
305
- const state = encodeURIComponent(oauthState);
464
+ const state = encodeURIComponent(oauthState.state);
465
+ const stateTtl = config.auth.oauthStateTtlSeconds;
466
+ const secureFlag = resolveCookieSecure(config.http.sessionCookieSecure, process.env.SECURE) ? " Secure;" : "";
467
+ res.setHeader(
468
+ "Set-Cookie",
469
+ `${login.OAUTH_STATE_COOKIE_NAME}=${oauthState.stateCookie}; Path=/; HttpOnly;${secureFlag} SameSite=Lax; Max-Age=${stateTtl}`
470
+ );
471
+ const pkceParams = oauthState.codeChallenge ? `&code_challenge=${encodeURIComponent(oauthState.codeChallenge)}&code_challenge_method=S256` : "";
306
472
  res.writeHead(302, {
307
- Location: `${provider.authorizationURL}?client_id=${clientId}&redirect_uri=${callbackUrl}&scope=${scope}&response_type=code&prompt=select_account&state=${state}`
473
+ Location: `${provider.authorizationURL}?client_id=${clientId}&redirect_uri=${callbackUrl}&scope=${scope}&response_type=code&prompt=select_account&state=${state}${pkceParams}`
308
474
  });
309
475
  res.end();
310
476
  return true;
311
477
  }
312
478
  const rateLimiting = config.rateLimiting;
313
- if (rateLimiting.defaultApiLimit !== false && rateLimiting.defaultApiLimit > 0) {
314
- const requesterIp = req.socket.remoteAddress ?? "unknown";
479
+ const requesterIp = resolveClientIp({
480
+ rawAddress: req.socket.remoteAddress,
481
+ headers: req.headers,
482
+ trustProxy: config.http.trustProxy,
483
+ trustedProxyHopCount: config.http.trustedProxyHopCount
484
+ });
485
+ const ipLimitCount = rateLimiting.defaultApiLimit !== false && rateLimiting.defaultApiLimit > 0 ? rateLimiting.defaultApiLimit : rateLimiting.auth.enabled && rateLimiting.auth.maxAttempts > 0 ? rateLimiting.auth.maxAttempts : null;
486
+ if (ipLimitCount !== null) {
487
+ const ipWindowMs = rateLimiting.defaultApiLimit === false ? rateLimiting.auth.windowMs : rateLimiting.windowMs;
315
488
  const { allowed, resetIn } = await checkRateLimit({
316
489
  key: `ip:${requesterIp}:auth:credentials`,
317
- limit: rateLimiting.defaultApiLimit,
318
- windowMs: rateLimiting.windowMs
490
+ limit: ipLimitCount,
491
+ windowMs: ipWindowMs
319
492
  });
320
493
  if (!allowed) {
321
494
  void dispatchHook2("rateLimitExceeded", {
322
495
  scope: "auth",
323
496
  key: `ip:${requesterIp}:auth:credentials`,
324
- limit: rateLimiting.defaultApiLimit,
325
- windowMs: rateLimiting.windowMs,
326
- count: rateLimiting.defaultApiLimit + 1,
497
+ limit: ipLimitCount,
498
+ windowMs: ipWindowMs,
499
+ count: ipLimitCount + 1,
327
500
  route: routePath,
328
501
  ip: requesterIp
329
502
  });
@@ -336,7 +509,7 @@ var handleAuthApiRoute = async ({
336
509
  return true;
337
510
  }
338
511
  }
339
- const result = await loginWithCredentials(params, { supersedeToken: token });
512
+ const result = await login.loginWithCredentials(params, { supersedeToken: token ?? void 0, requesterIp });
340
513
  if (!result?.status) {
341
514
  const reasonKey = typeof result?.reason === "string" && result.reason.length > 0 ? result.reason : "api.internalServerError";
342
515
  res.setHeader("content-type", "application/json; charset=utf-8");
@@ -344,7 +517,7 @@ var handleAuthApiRoute = async ({
344
517
  return true;
345
518
  }
346
519
  if (result.newToken) {
347
- if (token) await deleteSession(token, { skipSocketLogout: true });
520
+ if (token) await login.deleteSession(token, { skipSocketLogout: true });
348
521
  const requestedSessionMode = parseSessionBasedTokenHeader(req.headers["x-session-based-token"]);
349
522
  const useSessionBasedToken = requestedSessionMode ?? config.session.basedToken;
350
523
  if (shouldLogDev) getLogger().debug("http: setting cookie with new token");
@@ -363,18 +536,50 @@ var handleAuthApiRoute = async ({
363
536
  return true;
364
537
  };
365
538
 
539
+ // src/httpRoutes/authLogoutRoute.ts
540
+ import { getLogger as getLogger2, getProjectConfig as getProjectConfig6, tryCatch as tryCatch3 } from "@luckystack/core";
541
+ var handleAuthLogoutRoute = async ({ res, routePath, method, token }) => {
542
+ if (routePath !== "/auth/logout") return false;
543
+ if (method !== "POST") {
544
+ res.statusCode = 405;
545
+ res.setHeader("Allow", "POST");
546
+ res.setHeader("Content-Type", "application/json");
547
+ res.end(JSON.stringify({ status: "error", errorCode: "api.methodNotAllowed" }));
548
+ return true;
549
+ }
550
+ if (token) {
551
+ const login = await getLogin();
552
+ if (login) {
553
+ const [deleteError] = await tryCatch3(() => login.deleteSession(token));
554
+ if (deleteError) {
555
+ getLogger2().warn("http logout: deleteSession failed \u2014 clearing cookie anyway", { err: deleteError });
556
+ }
557
+ }
558
+ }
559
+ const http2 = getProjectConfig6().http;
560
+ const secure = process.env.SECURE === "true";
561
+ res.setHeader(
562
+ "Set-Cookie",
563
+ `${http2.sessionCookieName}=; HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=0; ${secure ? "Secure;" : ""}`
564
+ );
565
+ res.statusCode = 200;
566
+ res.setHeader("Content-Type", "application/json");
567
+ res.end(JSON.stringify({ status: "success", result: true }));
568
+ return true;
569
+ };
570
+
366
571
  // src/httpRoutes/authProvidersRoute.ts
367
- import { getOAuthProviders as getOAuthProviders2 } from "@luckystack/login";
368
- var handleAuthProvidersRoute = ({ req, res, routePath }) => {
369
- if (routePath !== "/auth/providers" || req.method !== "GET") return Promise.resolve(false);
572
+ var handleAuthProvidersRoute = async ({ req, res, routePath }) => {
573
+ if (routePath !== "/auth/providers" || req.method !== "GET") return false;
574
+ const login = await getLogin();
575
+ const providers = login ? login.getOAuthProviders().map((provider) => provider.name) : [];
370
576
  res.setHeader("Content-Type", "application/json");
371
- res.end(JSON.stringify({ providers: getOAuthProviders2().map((provider) => provider.name) }));
372
- return Promise.resolve(true);
577
+ res.end(JSON.stringify({ providers }));
578
+ return true;
373
579
  };
374
580
 
375
581
  // src/httpRoutes/authCallbackRoute.ts
376
- import { getLogger as getLogger2, getProjectConfig as getProjectConfig6 } from "@luckystack/core";
377
- import { deleteSession as deleteSession2, loginCallback } from "@luckystack/login";
582
+ import { getLogger as getLogger3, getProjectConfig as getProjectConfig7 } from "@luckystack/core";
378
583
  var handleAuthCallbackRoute = async ({
379
584
  req,
380
585
  res,
@@ -383,27 +588,34 @@ var handleAuthCallbackRoute = async ({
383
588
  sessionCookieOptions
384
589
  }) => {
385
590
  if (!routePath.startsWith("/auth/callback")) return false;
386
- const config = getProjectConfig6();
591
+ const login = await getLogin();
592
+ if (!login) {
593
+ res.writeHead(404, { "Content-Type": "text/plain" });
594
+ res.end("Auth is not enabled");
595
+ return true;
596
+ }
597
+ const config = getProjectConfig7();
387
598
  const sessionCookieName = config.http.sessionCookieName;
388
599
  const shouldLogDev = config.logging.devLogs;
389
600
  const publicOrigin = (config.app.publicUrl || "").replace(/\/+$/, "");
390
601
  const loginRedirect = config.loginRedirectUrl || "/";
391
- const baseLocation = /^https?:\/\//i.test(loginRedirect) ? loginRedirect : `${publicOrigin}${loginRedirect.startsWith("/") ? loginRedirect : `/${loginRedirect}`}` || "/";
392
- const callbackResult = await loginCallback(routePath, req, res, {
602
+ const baseLocation = /^https?:\/\//i.test(loginRedirect) ? loginRedirect : `${publicOrigin}${loginRedirect.startsWith("/") ? loginRedirect : `/${loginRedirect}`}`;
603
+ const callbackResult = await login.loginCallback(routePath, req, res, {
393
604
  defaultRedirectUrl: baseLocation,
394
- supersedeToken: token
605
+ supersedeToken: token ?? void 0
395
606
  });
396
607
  if (!callbackResult) {
397
608
  res.writeHead(401, { "Content-Type": "text/plain" });
398
609
  res.end("Login failed");
399
610
  return true;
400
611
  }
401
- if (token) await deleteSession2(token, { skipSocketLogout: true });
402
- if (shouldLogDev) getLogger2().debug("http: setting cookie or redirect with new token");
612
+ if (token) await login.deleteSession(token, { skipSocketLogout: true });
613
+ if (shouldLogDev) getLogger3().debug("http: setting cookie or redirect with new token");
403
614
  const { token: newToken, redirectUrl } = callbackResult;
404
615
  if (config.session.basedToken) {
405
- const separator = redirectUrl.includes("?") ? "&" : "?";
406
- res.writeHead(302, { Location: `${redirectUrl}${separator}token=${newToken}` });
616
+ const hashIndex = redirectUrl.indexOf("#");
617
+ const baseUrl = hashIndex === -1 ? redirectUrl : redirectUrl.slice(0, hashIndex);
618
+ res.writeHead(302, { Location: `${baseUrl}#token=${newToken}` });
407
619
  } else {
408
620
  res.setHeader("Set-Cookie", `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`);
409
621
  res.writeHead(302, { Location: redirectUrl });
@@ -417,13 +629,13 @@ import {
417
629
  captureException,
418
630
  dispatchHook as dispatchHook3,
419
631
  extractTokenFromRequest,
420
- getLogger as getLogger3,
421
- tryCatch as tryCatch3
632
+ getLogger as getLogger4,
633
+ tryCatch as tryCatch4
422
634
  } from "@luckystack/core";
423
635
  import { handleHttpApiRequest } from "@luckystack/api";
424
636
 
425
637
  // src/sse.ts
426
- import { getProjectConfig as getProjectConfig7 } from "@luckystack/core";
638
+ import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
427
639
  var isExpectingEventStream = (acceptHeader) => {
428
640
  if (!acceptHeader) return false;
429
641
  const value = Array.isArray(acceptHeader) ? acceptHeader.join(",") : acceptHeader;
@@ -431,7 +643,7 @@ var isExpectingEventStream = (acceptHeader) => {
431
643
  };
432
644
  var queryRequestsStream = (queryString) => {
433
645
  if (!queryString) return false;
434
- const { queryParam, enabledValue } = getProjectConfig7().http.stream;
646
+ const { queryParam, enabledValue } = getProjectConfig8().http.stream;
435
647
  const params = new URLSearchParams(queryString);
436
648
  const value = params.get(queryParam);
437
649
  return value === enabledValue || value === "1";
@@ -447,7 +659,7 @@ var initSseResponse = (res) => {
447
659
  Connection: "keep-alive",
448
660
  "X-Accel-Buffering": "no"
449
661
  });
450
- const connectedComment = getProjectConfig7().http.stream.connectedComment;
662
+ const connectedComment = getProjectConfig8().http.stream.connectedComment;
451
663
  if (connectedComment) {
452
664
  res.write(`${connectedComment}
453
665
 
@@ -460,6 +672,8 @@ var sendSseEvent = ({
460
672
  data
461
673
  }) => {
462
674
  if (res.writableEnded) return;
675
+ const contentType = res.getHeader("Content-Type");
676
+ if (typeof contentType !== "string" || !contentType.includes("text/event-stream")) return;
463
677
  res.write(`event: ${event}
464
678
  `);
465
679
  res.write(`data: ${JSON.stringify(data)}
@@ -467,6 +681,15 @@ var sendSseEvent = ({
467
681
  `);
468
682
  };
469
683
 
684
+ // src/httpRoutes/resolveRequesterIp.ts
685
+ import { getProjectConfig as getProjectConfig9, resolveClientIp as resolveClientIp2 } from "@luckystack/core";
686
+ var resolveRequesterIp = (req) => {
687
+ const trustProxy = getProjectConfig9().http.trustProxy;
688
+ const trustedProxyHopCount = getProjectConfig9().http.trustedProxyHopCount;
689
+ const rawRemoteAddress = req.socket.remoteAddress;
690
+ return rawRemoteAddress || trustProxy && (req.headers["x-forwarded-for"] || req.headers["x-real-ip"]) ? resolveClientIp2({ rawAddress: rawRemoteAddress, headers: req.headers, trustProxy, trustedProxyHopCount }) : void 0;
691
+ };
692
+
470
693
  // src/httpRoutes/apiRoute.ts
471
694
  var handleApiRoute = async ({
472
695
  req,
@@ -482,11 +705,15 @@ var handleApiRoute = async ({
482
705
  let streamClosed = false;
483
706
  if (useHttpStream) {
484
707
  initSseResponse(res);
485
- req.on("close", () => {
708
+ const markClosed = () => {
486
709
  streamClosed = true;
487
- });
710
+ };
711
+ req.on("close", markClosed);
712
+ req.on("error", markClosed);
713
+ req.on("aborted", markClosed);
714
+ res.on("error", markClosed);
488
715
  }
489
- const [error, handled] = await tryCatch3(async () => {
716
+ const [error, handled] = await tryCatch4(async () => {
490
717
  const httpToken = extractTokenFromRequest(req);
491
718
  const apiName = routePath.slice(5);
492
719
  if (!apiName) {
@@ -508,11 +735,12 @@ var handleApiRoute = async ({
508
735
  }
509
736
  const apiData = typeof params === "object" ? { ...params } : {};
510
737
  delete apiData.stream;
738
+ const requesterIp = resolveRequesterIp(req);
511
739
  const result = await handleHttpApiRequest({
512
740
  name: apiName,
513
741
  data: apiData,
514
742
  token: httpToken,
515
- requesterIp: req.socket.remoteAddress ?? void 0,
743
+ requesterIp,
516
744
  xLanguageHeader: req.headers["x-language"],
517
745
  acceptLanguageHeader: req.headers["accept-language"],
518
746
  method,
@@ -534,7 +762,7 @@ var handleApiRoute = async ({
534
762
  if (!error) {
535
763
  return handled ?? true;
536
764
  }
537
- getLogger3().error("http-api: top-level handler threw", error, { routePath, method, requestId });
765
+ getLogger4().error("http-api: top-level handler threw", error, { routePath, method, requestId });
538
766
  captureException(error, { routePath, method, requestId, source: "httpHandler.api" });
539
767
  void dispatchHook3("apiError", {
540
768
  route: routePath,
@@ -564,10 +792,9 @@ import {
564
792
  captureException as captureException2,
565
793
  dispatchHook as dispatchHook4,
566
794
  extractTokenFromRequest as extractTokenFromRequest2,
567
- getLogger as getLogger4,
568
- tryCatch as tryCatch4
795
+ getLogger as getLogger5,
796
+ tryCatch as tryCatch5
569
797
  } from "@luckystack/core";
570
- import { handleHttpSyncRequest } from "@luckystack/sync";
571
798
  var normalizeHttpSyncParams = (params) => {
572
799
  const source = params ?? {};
573
800
  const data = source.data && typeof source.data === "object" ? source.data : {};
@@ -588,15 +815,26 @@ var handleSyncRoute = async ({
588
815
  requestId
589
816
  }) => {
590
817
  if (!routePath.startsWith("/sync/")) return false;
818
+ const sync = capabilities.sync ? await getSync() : null;
819
+ if (!sync) {
820
+ res.setHeader("Content-Type", "application/json");
821
+ res.writeHead(404);
822
+ res.end(JSON.stringify({ status: "error", errorCode: "sync.disabled", message: "sync.disabled" }));
823
+ return true;
824
+ }
591
825
  const useHttpStream = shouldUseHttpStream({ acceptHeader: req.headers.accept, queryString });
592
826
  let streamClosed = false;
593
827
  if (useHttpStream) {
594
828
  initSseResponse(res);
595
- req.on("close", () => {
829
+ const markClosed = () => {
596
830
  streamClosed = true;
597
- });
831
+ };
832
+ req.on("close", markClosed);
833
+ req.on("error", markClosed);
834
+ req.on("aborted", markClosed);
835
+ res.on("error", markClosed);
598
836
  }
599
- const [error, handled] = await tryCatch4(async () => {
837
+ const [error, handled] = await tryCatch5(async () => {
600
838
  if (method !== "POST") {
601
839
  const response = {
602
840
  status: "error",
@@ -632,14 +870,15 @@ var handleSyncRoute = async ({
632
870
  return true;
633
871
  }
634
872
  const syncParams = normalizeHttpSyncParams(params);
635
- const result = await handleHttpSyncRequest({
873
+ const requesterIp = resolveRequesterIp(req);
874
+ const result = await sync.handleHttpSyncRequest({
636
875
  name: `sync/${syncName}`,
637
876
  cb: syncParams.cb,
638
877
  data: syncParams.data,
639
878
  receiver: syncParams.receiver,
640
879
  ignoreSelf: syncParams.ignoreSelf,
641
880
  token: httpToken,
642
- requesterIp: req.socket.remoteAddress ?? void 0,
881
+ requesterIp,
643
882
  xLanguageHeader: req.headers["x-language"],
644
883
  acceptLanguageHeader: req.headers["accept-language"],
645
884
  stream: useHttpStream ? (payload) => {
@@ -660,7 +899,7 @@ var handleSyncRoute = async ({
660
899
  if (!error) {
661
900
  return handled ?? true;
662
901
  }
663
- getLogger4().error("http-sync: top-level handler threw", error, { routePath, method, requestId });
902
+ getLogger5().error("http-sync: top-level handler threw", error, { routePath, method, requestId });
664
903
  captureException2(error, { routePath, method, requestId, source: "httpHandler.sync" });
665
904
  void dispatchHook4("syncError", {
666
905
  route: routePath,
@@ -685,7 +924,7 @@ var handleSyncRoute = async ({
685
924
  };
686
925
 
687
926
  // src/httpRoutes/customRoutes.ts
688
- import { captureException as captureException3, getLogger as getLogger5, tryCatch as tryCatch5 } from "@luckystack/core";
927
+ import { captureException as captureException3, getLogger as getLogger6, tryCatch as tryCatch6 } from "@luckystack/core";
689
928
 
690
929
  // src/customRoutesRegistry.ts
691
930
  var handlers = [];
@@ -706,9 +945,9 @@ var clearCustomRoutes = () => {
706
945
 
707
946
  // src/httpRoutes/customRoutes.ts
708
947
  var runHandler = async (handler, req, res, ctx, source) => {
709
- const [error, handled] = await tryCatch5(() => handler(req, res, ctx));
948
+ const [error, handled] = await tryCatch6(() => handler(req, res, ctx));
710
949
  if (error) {
711
- getLogger5().error(`${source} threw`, error, { routePath: ctx.routePath, method: ctx.method });
950
+ getLogger6().error(`${source} threw`, error, { routePath: ctx.routePath, method: ctx.method });
712
951
  captureException3(error, { routePath: ctx.routePath, method: ctx.method, source });
713
952
  if (!res.writableEnded) {
714
953
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -757,6 +996,7 @@ var handleCustomRoutes = async ({
757
996
  // src/httpRoutes/staticRoutes.ts
758
997
  import path from "path";
759
998
  var KNOWN_STATIC_FILE_REGEX = /^\/(assets\/[a-zA-Z0-9_/-]+|[a-zA-Z0-9_-]+)\.(png|jpg|jpeg|gif|svg|html|css|js)$/;
999
+ var SERVE_DENYLIST_REGEX = /(^\/server\.js$)|(\.map$)/;
760
1000
  var serveWithRewrittenUrl = async (serveFile, req, res, rewrittenUrl) => {
761
1001
  const originalUrl = req.url;
762
1002
  req.url = rewrittenUrl;
@@ -772,14 +1012,18 @@ var handleStaticAndSpaFallback = async ({
772
1012
  routePath,
773
1013
  options
774
1014
  }) => {
775
- if (routePath.includes("/assets/")) {
1015
+ if (SERVE_DENYLIST_REGEX.test(routePath)) {
1016
+ res.writeHead(404, { "Content-Type": "text/plain" });
1017
+ res.end("Not Found");
1018
+ return true;
1019
+ }
1020
+ if (routePath.startsWith("/assets/")) {
776
1021
  if (!options.serveFile) {
777
1022
  res.writeHead(404);
778
1023
  res.end("Not Found");
779
1024
  return true;
780
1025
  }
781
- const assetPath = routePath.slice(routePath.indexOf("/assets/"));
782
- await serveWithRewrittenUrl(options.serveFile, req, res, assetPath);
1026
+ await serveWithRewrittenUrl(options.serveFile, req, res, routePath);
783
1027
  return true;
784
1028
  }
785
1029
  if (KNOWN_STATIC_FILE_REGEX.test(routePath)) {
@@ -805,21 +1049,10 @@ var handleStaticAndSpaFallback = async ({
805
1049
  return true;
806
1050
  };
807
1051
 
808
- // src/originExemptRegistry.ts
809
- var exemptPaths = [];
810
- var registerOriginExemptPath = (matcher) => {
811
- exemptPaths.push(matcher);
812
- };
813
- var getOriginExemptPaths = () => exemptPaths;
814
- var clearOriginExemptPaths = () => {
815
- exemptPaths.length = 0;
816
- };
817
- var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
818
-
819
1052
  // src/httpHandler.ts
820
1053
  var buildSessionCookieOptions = (sessionExpiryDays, secure, http2) => `HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=${60 * 60 * 24 * sessionExpiryDays}; ${secure ? "Secure;" : ""}`;
821
1054
  var setSecurityHeaders = (req, res, origin) => {
822
- const { cors, securityHeaders } = getProjectConfig8().http;
1055
+ const { cors, securityHeaders } = getProjectConfig10().http;
823
1056
  res.setHeader("Access-Control-Allow-Origin", origin);
824
1057
  res.setHeader("Access-Control-Allow-Methods", cors.allowedMethods);
825
1058
  res.setHeader("Access-Control-Allow-Headers", cors.allowedHeaders);
@@ -833,15 +1066,16 @@ var setSecurityHeaders = (req, res, origin) => {
833
1066
  res.setHeader("X-Content-Type-Options", securityHeaders.contentTypeOptions);
834
1067
  const builder = getSecurityHeadersBuilder();
835
1068
  if (builder) {
836
- try {
1069
+ const [error] = tryCatchSync(() => {
837
1070
  const custom = builder(req);
838
1071
  if (custom) {
839
1072
  for (const [name, value] of Object.entries(custom)) {
840
1073
  res.setHeader(name, value);
841
1074
  }
842
1075
  }
843
- } catch (error) {
844
- getLogger6().warn("securityHeadersBuilder threw \u2014 falling back to defaults", { err: error });
1076
+ });
1077
+ if (error) {
1078
+ getLogger7().warn("securityHeadersBuilder threw \u2014 falling back to defaults", { err: error });
845
1079
  }
846
1080
  }
847
1081
  };
@@ -853,6 +1087,7 @@ var PRE_PARAMS_ROUTES = [
853
1087
  handleHealthRoute,
854
1088
  handleTestResetRoute,
855
1089
  handleAuthProvidersRoute,
1090
+ handleAuthLogoutRoute,
856
1091
  handlePreParamsCustomRoutes
857
1092
  ];
858
1093
  var POST_PARAMS_ROUTES = [
@@ -901,7 +1136,7 @@ var refreshSessionCookieIfPresent = async ({
901
1136
  }) => {
902
1137
  const hasTokenCookie = hasCookie(req.headers.cookie, sessionCookieName);
903
1138
  if (!hasTokenCookie || !token) return;
904
- const currentSession = await getSession3(token);
1139
+ const currentSession = await readSession3(token);
905
1140
  if (currentSession?.id) {
906
1141
  res.setHeader("Set-Cookie", `${sessionCookieName}=${token}; ${sessionCookieOptions}`);
907
1142
  }
@@ -919,25 +1154,28 @@ var parseRequestParams = async ({
919
1154
  if (res.writableEnded) return null;
920
1155
  if (params && typeof params === "object" && Object.keys(params).length > 0) {
921
1156
  if (shouldLogDev) {
922
- getLogger6().debug(`[${requestId}] ${method} ${routePath}`, {
1157
+ getLogger7().debug(`[${requestId}] ${method} ${routePath}`, {
923
1158
  params: sanitizeForLog(params)
924
1159
  });
925
1160
  }
926
1161
  } else {
927
1162
  if (shouldLogDev) {
928
- getLogger6().debug(`[${requestId}] ${method} ${routePath}`);
1163
+ getLogger7().debug(`[${requestId}] ${method} ${routePath}`);
929
1164
  }
930
1165
  params = {};
931
1166
  }
932
1167
  return params;
933
1168
  };
934
1169
  var handleHttpRequest = async (req, res, options) => {
935
- const config = getProjectConfig8();
1170
+ const config = getProjectConfig10();
936
1171
  const shouldLogDev = config.logging.devLogs;
937
1172
  const sessionCookieName = config.http.sessionCookieName;
938
1173
  const sessionCookieOptions = buildSessionCookieOptions(
939
1174
  config.session.expiryDays,
940
- process.env.SECURE === "true",
1175
+ //? Honor the explicit `http.sessionCookieSecure` override (CORE-39), else the
1176
+ //? `SECURE` env flag — shared with the OAuth state cookie via
1177
+ //? `resolveCookieSecure` so the two can never drift (WAVE4).
1178
+ resolveCookieSecure(config.http.sessionCookieSecure, process.env.SECURE),
941
1179
  config.http
942
1180
  );
943
1181
  const url = req.url ?? "/";
@@ -1018,6 +1256,8 @@ import {
1018
1256
  allowedOrigin as allowedOrigin2,
1019
1257
  applySocketMiddlewares,
1020
1258
  attachSocketRedisAdapter,
1259
+ formatRoomName,
1260
+ redis as redis3,
1021
1261
  setIoInstance,
1022
1262
  socketEventNames,
1023
1263
  buildJoinRoomResponseEventName,
@@ -1025,27 +1265,21 @@ import {
1025
1265
  buildGetJoinedRoomsResponseEventName,
1026
1266
  extractLanguageFromHeader,
1027
1267
  extractTokenFromSocket,
1028
- getLogger as getLogger7,
1029
- getProjectConfig as getProjectConfig9,
1268
+ getLogger as getLogger8,
1269
+ getProjectConfig as getProjectConfig11,
1030
1270
  dispatchHook as dispatchHook6,
1031
1271
  normalizeErrorResponse,
1032
- tryCatch as tryCatch6
1272
+ readSession as readSession4,
1273
+ writeSession,
1274
+ tryCatch as tryCatch7
1033
1275
  } from "@luckystack/core";
1034
1276
  import { handleApiRequest } from "@luckystack/api";
1035
- import { handleSyncRequest } from "@luckystack/sync";
1036
- import { getSession as getSession4, saveSession } from "@luckystack/login";
1037
- import {
1038
- initActivityBroadcaster,
1039
- socketConnected,
1040
- socketDisconnecting,
1041
- socketLeaveRoom
1042
- } from "@luckystack/presence";
1043
1277
  var sessionLocks = /* @__PURE__ */ new Map();
1044
1278
  var withSessionLock = async (token, fn) => {
1045
1279
  const prev = sessionLocks.get(token) ?? Promise.resolve();
1046
1280
  const next = prev.then(fn, fn);
1047
1281
  sessionLocks.set(token, next);
1048
- await tryCatch6(() => next);
1282
+ await tryCatch7(() => next);
1049
1283
  if (sessionLocks.get(token) === next) sessionLocks.delete(token);
1050
1284
  };
1051
1285
  var getVisibleSocketRooms = (socket, token) => {
@@ -1061,15 +1295,290 @@ var sanitizeSessionRoomKeys = (session) => {
1061
1295
  const { code: _legacyCode, codes: _legacyCodes, ...sanitizedSession } = session;
1062
1296
  return sanitizedSession;
1063
1297
  };
1298
+ var validateRoomRequest = (socket, responseIndex, token, group, preferredLocale, buildResponseEventName) => {
1299
+ if (typeof responseIndex !== "number") return false;
1300
+ if (!token) {
1301
+ socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
1302
+ response: { status: "error", errorCode: "auth.required" },
1303
+ preferredLocale
1304
+ }));
1305
+ return false;
1306
+ }
1307
+ if (!group) {
1308
+ socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
1309
+ response: { status: "error", errorCode: "room.invalid" },
1310
+ preferredLocale
1311
+ }));
1312
+ return false;
1313
+ }
1314
+ return true;
1315
+ };
1316
+ var executeRoomMutation = async (opts) => {
1317
+ const {
1318
+ socket,
1319
+ token,
1320
+ group,
1321
+ responseIndex,
1322
+ preferredLocale,
1323
+ shouldLogDev,
1324
+ buildResponseEventName,
1325
+ preHook,
1326
+ postHook,
1327
+ blockedErrorCode,
1328
+ logVerb,
1329
+ mutate
1330
+ } = opts;
1331
+ const session = await readSession4(token);
1332
+ if (!session) {
1333
+ socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
1334
+ response: { status: "error", errorCode: "session.notFound" },
1335
+ preferredLocale
1336
+ }));
1337
+ return;
1338
+ }
1339
+ const preResult = await dispatchHook6(preHook, { token, room: group });
1340
+ if (preResult.stopped) {
1341
+ socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
1342
+ response: {
1343
+ status: "error",
1344
+ errorCode: preResult.signal.errorCode || blockedErrorCode
1345
+ },
1346
+ preferredLocale,
1347
+ userLanguage: session.language
1348
+ }));
1349
+ return;
1350
+ }
1351
+ const existingRoomCodes = getSessionRoomCodes(session);
1352
+ const roomPurpose = preHook === "preRoomJoin" ? "join" : "leave";
1353
+ const physicalRoom = formatRoomName(group, { purpose: roomPurpose, userId: session.id });
1354
+ const nextRoomCodes = await mutate(socket, physicalRoom, group, existingRoomCodes);
1355
+ const sanitizedSession = sanitizeSessionRoomKeys(session);
1356
+ await writeSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1357
+ const visibleRooms = getVisibleSocketRooms(socket, token);
1358
+ socket.emit(buildResponseEventName(responseIndex), { rooms: visibleRooms });
1359
+ if (shouldLogDev) {
1360
+ getLogger8().debug(`Socket ${socket.id} ${logVerb} group ${group}`);
1361
+ }
1362
+ void dispatchHook6(postHook, { token, room: group, allRooms: visibleRooms });
1363
+ };
1364
+ var registerApiAndSyncEvents = (ctx) => {
1365
+ const { socket, token } = ctx;
1366
+ socket.on(socketEventNames.apiRequest, (msg) => {
1367
+ void handleApiRequest({ msg, socket, token });
1368
+ });
1369
+ if (capabilities.sync) {
1370
+ socket.on(socketEventNames.sync, (msg) => {
1371
+ void (async () => {
1372
+ const sync = await getSync();
1373
+ if (sync) await sync.handleSyncRequest({ msg, socket, token });
1374
+ })();
1375
+ });
1376
+ }
1377
+ };
1378
+ var registerCancellationEvents = (ctx) => {
1379
+ const { socket } = ctx;
1380
+ socket.on(socketEventNames.syncCancel, (data) => {
1381
+ const cb = typeof data.cb === "string" ? data.cb : null;
1382
+ if (!cb) return;
1383
+ abortSyncByCb(socket.id, cb);
1384
+ });
1385
+ socket.on(socketEventNames.apiCancel, (data) => {
1386
+ const responseIndex = data.responseIndex;
1387
+ if (typeof responseIndex !== "number" && typeof responseIndex !== "string") return;
1388
+ abortApiByResponseIndex(socket.id, responseIndex);
1389
+ });
1390
+ };
1391
+ var registerRoomEvents = (ctx) => {
1392
+ const { socket, token, preferredLocale, shouldLogDev } = ctx;
1393
+ socket.on(socketEventNames.joinRoom, (data) => {
1394
+ const group = typeof data.group === "string" ? data.group.trim() : "";
1395
+ const responseIndex = data.responseIndex;
1396
+ if (!validateRoomRequest(socket, responseIndex, token, group, preferredLocale, buildJoinRoomResponseEventName)) return;
1397
+ if (!token) return;
1398
+ void withSessionLock(token, async () => {
1399
+ await executeRoomMutation({
1400
+ socket,
1401
+ token,
1402
+ group,
1403
+ responseIndex,
1404
+ preferredLocale,
1405
+ shouldLogDev,
1406
+ buildResponseEventName: buildJoinRoomResponseEventName,
1407
+ preHook: "preRoomJoin",
1408
+ postHook: "postRoomJoin",
1409
+ blockedErrorCode: "room.joinBlocked",
1410
+ logVerb: "joined",
1411
+ mutate: async (sock, physicalRoom, rawGroup, existingCodes) => {
1412
+ const nextCodes = [.../* @__PURE__ */ new Set([...existingCodes, rawGroup])];
1413
+ await sock.join(physicalRoom);
1414
+ return nextCodes;
1415
+ }
1416
+ });
1417
+ });
1418
+ });
1419
+ socket.on(socketEventNames.leaveRoom, (data) => {
1420
+ const group = typeof data.group === "string" ? data.group.trim() : "";
1421
+ const responseIndex = data.responseIndex;
1422
+ if (!validateRoomRequest(socket, responseIndex, token, group, preferredLocale, buildLeaveRoomResponseEventName)) return;
1423
+ if (!token) return;
1424
+ void withSessionLock(token, async () => {
1425
+ await executeRoomMutation({
1426
+ socket,
1427
+ token,
1428
+ group,
1429
+ responseIndex,
1430
+ preferredLocale,
1431
+ shouldLogDev,
1432
+ buildResponseEventName: buildLeaveRoomResponseEventName,
1433
+ preHook: "preRoomLeave",
1434
+ postHook: "postRoomLeave",
1435
+ blockedErrorCode: "room.leaveBlocked",
1436
+ logVerb: "left",
1437
+ mutate: async (sock, physicalRoom, rawGroup, existingCodes) => {
1438
+ const nextCodes = existingCodes.filter((c) => c !== rawGroup);
1439
+ await sock.leave(physicalRoom);
1440
+ return nextCodes;
1441
+ }
1442
+ });
1443
+ });
1444
+ });
1445
+ socket.on(socketEventNames.getJoinedRooms, (data) => {
1446
+ const responseIndex = data.responseIndex;
1447
+ if (typeof responseIndex !== "number") return;
1448
+ if (!token) {
1449
+ socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
1450
+ ...normalizeErrorResponse({
1451
+ response: { status: "error", errorCode: "auth.required" },
1452
+ preferredLocale
1453
+ }),
1454
+ rooms: []
1455
+ });
1456
+ return;
1457
+ }
1458
+ socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
1459
+ rooms: getVisibleSocketRooms(socket, token)
1460
+ });
1461
+ });
1462
+ };
1463
+ var registerDisconnectEvent = (ctx) => {
1464
+ const { socket, token, activityBroadcasterEnabled, shouldLogDev } = ctx;
1465
+ socket.on(socketEventNames.disconnect, (reason) => {
1466
+ abortAllForSocket(socket.id);
1467
+ void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
1468
+ if (activityBroadcasterEnabled) {
1469
+ void getPresence().then((presence) => {
1470
+ presence?.clearActivity(socket.id);
1471
+ });
1472
+ }
1473
+ if (activityBroadcasterEnabled && token) {
1474
+ void (async () => {
1475
+ const presence = await getPresence();
1476
+ if (presence) presence.socketDisconnecting({ token, socket, reason });
1477
+ })();
1478
+ } else {
1479
+ if (!token) return;
1480
+ if (shouldLogDev) {
1481
+ getLogger8().debug(`user disconnected`, { reason });
1482
+ }
1483
+ }
1484
+ });
1485
+ };
1486
+ var registerUpdateLocationEvent = (ctx) => {
1487
+ const { socket, token, activityBroadcasterEnabled, locationProviderEnabled, shouldLogDev } = ctx;
1488
+ socket.on(
1489
+ socketEventNames.updateLocation,
1490
+ (newLocation) => {
1491
+ if (!token) return;
1492
+ if (!locationProviderEnabled) return;
1493
+ if (shouldLogDev) {
1494
+ getLogger8().debug("updating location", { pathName: newLocation.pathName });
1495
+ }
1496
+ void withSessionLock(token, async () => {
1497
+ let returnedUser = null;
1498
+ if (activityBroadcasterEnabled) {
1499
+ const presence = await getPresence();
1500
+ if (presence) {
1501
+ returnedUser = await presence.socketLeaveRoom({ token, socket, newPath: newLocation.pathName });
1502
+ }
1503
+ }
1504
+ const user = returnedUser ?? await readSession4(token);
1505
+ if (!user) return;
1506
+ const extendedUser = user;
1507
+ const oldLocation = extendedUser.location;
1508
+ const sanitizedUser = sanitizeSessionRoomKeys({ ...extendedUser, location: newLocation });
1509
+ await writeSession(token, sanitizedUser);
1510
+ void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
1511
+ });
1512
+ }
1513
+ );
1514
+ };
1515
+ var registerActivityEvents = (ctx) => {
1516
+ const { socket, token, activityBroadcasterEnabled, io } = ctx;
1517
+ if (activityBroadcasterEnabled && token) {
1518
+ void (async () => {
1519
+ const presence = await getPresence();
1520
+ if (presence) presence.initActivityBroadcaster({ socket, token });
1521
+ })();
1522
+ }
1523
+ if (activityBroadcasterEnabled) {
1524
+ void (async () => {
1525
+ const presence = await getPresence();
1526
+ if (!presence) return;
1527
+ presence.startActivitySampler({ io });
1528
+ presence.recordActivity(socket.id);
1529
+ })();
1530
+ socket.on(socketEventNames.activity, () => {
1531
+ void getPresence().then((presence) => {
1532
+ presence?.recordActivity(socket.id);
1533
+ });
1534
+ });
1535
+ socket.on(socketEventNames.intentionalReconnect, () => {
1536
+ void getPresence().then((presence) => {
1537
+ presence?.recordActivity(socket.id);
1538
+ });
1539
+ });
1540
+ }
1541
+ };
1542
+ var rejoinPersistedRooms = (ctx) => {
1543
+ const { socket, token, shouldLogDev } = ctx;
1544
+ if (!token) return;
1545
+ void (async () => {
1546
+ const [rejoinError, codes] = await tryCatch7(async () => {
1547
+ await socket.join(token);
1548
+ const session = await readSession4(token);
1549
+ const roomCodes = session ? getSessionRoomCodes(session) : [];
1550
+ const userId = session?.id ?? null;
1551
+ for (const roomCode of roomCodes) {
1552
+ await socket.join(formatRoomName(roomCode, { purpose: "join", userId }));
1553
+ }
1554
+ return roomCodes;
1555
+ });
1556
+ if (rejoinError) {
1557
+ getLogger8().warn(`socket room rejoin failed for ${socket.id}`, { error: rejoinError.message });
1558
+ return;
1559
+ }
1560
+ if (shouldLogDev) {
1561
+ getLogger8().debug(`socket ${socket.id} (re)joined rooms: ${(codes ?? []).join(", ") || "(none)"}`);
1562
+ }
1563
+ })();
1564
+ };
1064
1565
  var loadSocket = (httpServer, options = {}) => {
1065
- const config = getProjectConfig9();
1566
+ const config = getProjectConfig11();
1066
1567
  const shouldLogDev = config.logging.devLogs;
1067
1568
  const shouldLogSocketStartup = config.logging.socketStartup;
1068
1569
  const io = new SocketIOServer(httpServer, {
1069
1570
  cors: {
1070
1571
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
1071
1572
  origin: (origin, callback) => {
1072
- if (!origin || allowedOrigin2(origin)) {
1573
+ if (!origin) {
1574
+ if (config.http.cors.allowOriginless) {
1575
+ callback(null, true);
1576
+ } else {
1577
+ callback(new Error("Origin-less WebSocket upgrades are not allowed"));
1578
+ }
1579
+ return;
1580
+ }
1581
+ if (allowedOrigin2(origin)) {
1073
1582
  callback(null, true);
1074
1583
  } else {
1075
1584
  callback(new Error("Not allowed by CORS"));
@@ -1083,225 +1592,53 @@ var loadSocket = (httpServer, options = {}) => {
1083
1592
  });
1084
1593
  setIoInstance(io);
1085
1594
  applySocketMiddlewares(io);
1086
- attachSocketRedisAdapter(io);
1595
+ const pubClient = redis3.duplicate();
1596
+ const subClient = redis3.duplicate();
1597
+ attachSocketRedisAdapter(io, { pubClient, subClient });
1087
1598
  if (shouldLogSocketStartup) {
1088
- getLogger7().info("SocketIO server initialized (redis adapter attached)");
1599
+ getLogger8().info("SocketIO server initialized (redis adapter attached)");
1089
1600
  }
1090
1601
  io.on(socketEventNames.connect, (socket) => {
1091
1602
  const token = extractTokenFromSocket(socket);
1092
1603
  const activityBroadcasterEnabled = config.socketActivityBroadcaster ?? false;
1093
1604
  const locationProviderEnabled = config.locationProviderEnabled ?? false;
1094
1605
  const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]) || void 0;
1095
- if (token) {
1096
- void socketConnected({ token, io });
1606
+ if (token && capabilities.presence) {
1607
+ void (async () => {
1608
+ const presence = await getPresence();
1609
+ if (presence) await presence.socketConnected({ token, io });
1610
+ })();
1097
1611
  }
1098
1612
  void dispatchHook6("onSocketConnect", {
1099
1613
  socketId: socket.id,
1100
1614
  token,
1101
1615
  ip: socket.handshake.address
1102
1616
  });
1103
- socket.on(socketEventNames.apiRequest, (msg) => {
1104
- void handleApiRequest({ msg, socket, token });
1105
- });
1106
- socket.on(socketEventNames.sync, (msg) => {
1107
- void handleSyncRequest({ msg, socket, token });
1108
- });
1109
- socket.on(socketEventNames.syncCancel, (data) => {
1110
- const cb = typeof data.cb === "string" ? data.cb : null;
1111
- if (!cb) return;
1112
- abortSyncByCb(socket.id, cb);
1113
- });
1114
- socket.on(socketEventNames.apiCancel, (data) => {
1115
- const responseIndex = data.responseIndex;
1116
- if (typeof responseIndex !== "number" && typeof responseIndex !== "string") return;
1117
- abortApiByResponseIndex(socket.id, responseIndex);
1118
- });
1119
- socket.on(socketEventNames.joinRoom, (data) => {
1120
- const group = typeof data.group === "string" ? data.group.trim() : "";
1121
- const responseIndex = data.responseIndex;
1122
- if (typeof responseIndex !== "number") return;
1123
- if (!token) {
1124
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1125
- response: { status: "error", errorCode: "auth.required" },
1126
- preferredLocale
1127
- }));
1128
- return;
1129
- }
1130
- if (!group) {
1131
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1132
- response: { status: "error", errorCode: "room.invalid" },
1133
- preferredLocale
1134
- }));
1135
- return;
1136
- }
1137
- void withSessionLock(token, async () => {
1138
- const session = await getSession4(token);
1139
- if (!session) {
1140
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1141
- response: { status: "error", errorCode: "session.notFound" },
1142
- preferredLocale
1143
- }));
1144
- return;
1145
- }
1146
- const preResult = await dispatchHook6("preRoomJoin", { token, room: group });
1147
- if (preResult.stopped) {
1148
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1149
- response: {
1150
- status: "error",
1151
- errorCode: preResult.signal.errorCode || "room.joinBlocked"
1152
- },
1153
- preferredLocale,
1154
- userLanguage: session.language
1155
- }));
1156
- return;
1157
- }
1158
- const existingRoomCodes = getSessionRoomCodes(session);
1159
- const nextRoomCodes = [.../* @__PURE__ */ new Set([...existingRoomCodes, group])];
1160
- await socket.join(group);
1161
- const sanitizedSession = sanitizeSessionRoomKeys(session);
1162
- await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1163
- const visibleRooms = getVisibleSocketRooms(socket, token);
1164
- socket.emit(buildJoinRoomResponseEventName(responseIndex), { rooms: visibleRooms });
1165
- if (shouldLogDev) {
1166
- getLogger7().debug(`Socket ${socket.id} joined group ${group}`);
1167
- }
1168
- void dispatchHook6("postRoomJoin", { token, room: group, allRooms: visibleRooms });
1169
- });
1170
- });
1171
- socket.on(socketEventNames.leaveRoom, (data) => {
1172
- const group = typeof data.group === "string" ? data.group.trim() : "";
1173
- const responseIndex = data.responseIndex;
1174
- if (typeof responseIndex !== "number") return;
1175
- if (!token) {
1176
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1177
- response: { status: "error", errorCode: "auth.required" },
1178
- preferredLocale
1179
- }));
1180
- return;
1181
- }
1182
- if (!group) {
1183
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1184
- response: { status: "error", errorCode: "room.invalid" },
1185
- preferredLocale
1186
- }));
1187
- return;
1188
- }
1189
- void withSessionLock(token, async () => {
1190
- const session = await getSession4(token);
1191
- if (!session) {
1192
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1193
- response: { status: "error", errorCode: "session.notFound" },
1194
- preferredLocale
1195
- }));
1196
- return;
1197
- }
1198
- const preResult = await dispatchHook6("preRoomLeave", { token, room: group });
1199
- if (preResult.stopped) {
1200
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1201
- response: {
1202
- status: "error",
1203
- errorCode: preResult.signal.errorCode || "room.leaveBlocked"
1204
- },
1205
- preferredLocale,
1206
- userLanguage: session.language
1207
- }));
1208
- return;
1209
- }
1210
- const existingRoomCodes = getSessionRoomCodes(session);
1211
- const nextRoomCodes = existingRoomCodes.filter((roomCode) => roomCode !== group);
1212
- await socket.leave(group);
1213
- const sanitizedSession = sanitizeSessionRoomKeys(session);
1214
- await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1215
- const visibleRooms = getVisibleSocketRooms(socket, token);
1216
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), { rooms: visibleRooms });
1217
- if (shouldLogDev) {
1218
- getLogger7().debug(`Socket ${socket.id} left group ${group}`);
1219
- }
1220
- void dispatchHook6("postRoomLeave", { token, room: group, allRooms: visibleRooms });
1221
- });
1222
- });
1223
- socket.on(socketEventNames.getJoinedRooms, (data) => {
1224
- const responseIndex = data.responseIndex;
1225
- if (typeof responseIndex !== "number") return;
1226
- if (!token) {
1227
- socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
1228
- ...normalizeErrorResponse({
1229
- response: { status: "error", errorCode: "auth.required" },
1230
- preferredLocale
1231
- }),
1232
- rooms: []
1233
- });
1234
- return;
1235
- }
1236
- socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
1237
- rooms: getVisibleSocketRooms(socket, token)
1238
- });
1239
- });
1240
- socket.on(socketEventNames.disconnect, (reason) => {
1241
- abortAllForSocket(socket.id);
1242
- void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
1243
- if (activityBroadcasterEnabled && token) {
1244
- void socketDisconnecting({ token, socket, reason });
1245
- } else {
1246
- if (!token) return;
1247
- if (shouldLogDev) {
1248
- getLogger7().debug(`user disconnected`, { reason });
1249
- }
1250
- }
1251
- });
1252
- socket.on(
1253
- socketEventNames.updateLocation,
1254
- (newLocation) => {
1255
- if (!token) return;
1256
- if (!locationProviderEnabled) return;
1257
- if (shouldLogDev) {
1258
- getLogger7().debug("updating location", { pathName: newLocation.pathName });
1259
- }
1260
- void withSessionLock(token, async () => {
1261
- let returnedUser = null;
1262
- if (activityBroadcasterEnabled) {
1263
- returnedUser = await socketLeaveRoom({ token, socket, newPath: newLocation.pathName });
1264
- }
1265
- const user = returnedUser ?? await getSession4(token);
1266
- if (!user) return;
1267
- const extendedUser = user;
1268
- const oldLocation = extendedUser.location;
1269
- extendedUser.location = newLocation;
1270
- await saveSession(token, user);
1271
- void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
1272
- });
1273
- }
1274
- );
1275
- if (activityBroadcasterEnabled && token) {
1276
- initActivityBroadcaster({ socket, token });
1277
- }
1278
- if (token) {
1279
- void (async () => {
1280
- const [rejoinError, codes] = await tryCatch6(async () => {
1281
- await socket.join(token);
1282
- const session = await getSession4(token);
1283
- const roomCodes = session ? getSessionRoomCodes(session) : [];
1284
- for (const roomCode of roomCodes) {
1285
- await socket.join(roomCode);
1286
- }
1287
- return roomCodes;
1288
- });
1289
- if (rejoinError) {
1290
- getLogger7().warn(`socket room rejoin failed for ${socket.id}`, { error: rejoinError.message });
1291
- return;
1292
- }
1293
- if (shouldLogDev) {
1294
- getLogger7().debug(`socket ${socket.id} (re)joined rooms: ${(codes ?? []).join(", ") || "(none)"}`);
1295
- }
1296
- })();
1297
- }
1617
+ const ctx = {
1618
+ socket,
1619
+ token,
1620
+ preferredLocale,
1621
+ activityBroadcasterEnabled,
1622
+ locationProviderEnabled,
1623
+ shouldLogDev,
1624
+ io
1625
+ };
1626
+ registerApiAndSyncEvents(ctx);
1627
+ registerCancellationEvents(ctx);
1628
+ registerRoomEvents(ctx);
1629
+ registerDisconnectEvent(ctx);
1630
+ registerUpdateLocationEvent(ctx);
1631
+ registerActivityEvents(ctx);
1632
+ rejoinPersistedRooms(ctx);
1298
1633
  });
1299
- return io;
1634
+ return { io, adapterClients: { pubClient, subClient } };
1300
1635
  };
1301
1636
 
1302
1637
  // src/verifyBootstrap.ts
1303
1638
  import {
1304
- getLogger as getLogger8,
1639
+ collectSynchronizedEnvKeys,
1640
+ getLogger as getLogger9,
1641
+ getProjectConfig as getProjectConfig12,
1305
1642
  isDeployConfigRegistered,
1306
1643
  isLocalizedNormalizerRegistered,
1307
1644
  isProjectConfigRegistered,
@@ -1328,8 +1665,12 @@ var verifyBootstrap = async (requirements = {}) => {
1328
1665
  }
1329
1666
  }
1330
1667
  if (requirements.requireOAuthProviders) {
1331
- const { getOAuthProviders: getOAuthProviders3 } = await import("@luckystack/login");
1332
- if (getOAuthProviders3().length <= 1) {
1668
+ const login = await getLogin();
1669
+ if (!login) {
1670
+ missing.push(
1671
+ "OAuth providers required (`requireOAuthProviders`) but `@luckystack/login` is not installed. Install it, or drop the requirement if this app has no auth."
1672
+ );
1673
+ } else if (login.getOAuthProviders().length <= 1) {
1333
1674
  missing.push(
1334
1675
  "OAuth providers \u2014 call `registerOAuthProviders([...])` from `luckystack/login/oauthProviders.ts` (or skip this check if your app uses credentials only)."
1335
1676
  );
@@ -1341,7 +1682,7 @@ var verifyBootstrap = async (requirements = {}) => {
1341
1682
  "RuntimeMapsProvider \u2014 call `registerRuntimeMapsProvider({...})` from `server/prod/runtimeMaps.ts`. Without it, every api/sync request returns notFound."
1342
1683
  );
1343
1684
  } else {
1344
- getLogger8().warn(
1685
+ getLogger9().warn(
1345
1686
  "[LuckyStack] No RuntimeMapsProvider registered \u2014 api/sync requests will resolve to empty maps. Devkit hot-reload usually registers one automatically."
1346
1687
  );
1347
1688
  }
@@ -1352,11 +1693,20 @@ var verifyBootstrap = async (requirements = {}) => {
1352
1693
  "LocalizedNormalizer \u2014 call `registerLocalizedNormalizer({...})` from your bootstrap. Without it, error response messages will be the raw errorCode (no i18n)."
1353
1694
  );
1354
1695
  } else {
1355
- getLogger8().warn(
1696
+ getLogger9().warn(
1356
1697
  "[LuckyStack] No LocalizedNormalizer registered \u2014 error messages will pass through as the raw errorCode."
1357
1698
  );
1358
1699
  }
1359
1700
  }
1701
+ if (isProjectConfigRegistered()) {
1702
+ const synchronizedKeyCount = collectSynchronizedEnvKeys().length;
1703
+ const healthHashMode = getProjectConfig12().http.healthHash.mode;
1704
+ if (synchronizedKeyCount > 0 && healthHashMode === "plain") {
1705
+ getLogger9().warn(
1706
+ `[LuckyStack] SECURITY: /_health exposes UNSALTED sha256 fingerprints of ${String(synchronizedKeyCount)} synchronized env secret(s) by default. Set \`http.healthHash.mode\` to 'hmac' (or 'salted' with salt '@bootUuid') to stop publishing brute-forceable secret fingerprints to unauthenticated callers.`
1707
+ );
1708
+ }
1709
+ }
1360
1710
  if (missing.length === 0) return;
1361
1711
  const detail = missing.map((line, idx) => ` ${idx + 1}. ${line}`).join("\n");
1362
1712
  throw new Error(
@@ -1371,58 +1721,25 @@ var verifyBootstrap = async (requirements = {}) => {
1371
1721
 
1372
1722
  // src/runtimeMapsLoader.ts
1373
1723
  import {
1724
+ getLogger as getLogger10,
1374
1725
  registerRuntimeMapsProvider
1375
1726
  } from "@luckystack/core";
1376
-
1377
- // src/argv.ts
1378
- var parsedBundles = [];
1379
- var parsedPort = null;
1380
- var hasRun = false;
1381
- var PORT_PATTERN = /^\d+$/;
1382
- var parseServerArgv = (argv) => {
1383
- if (argv.length > 2) {
1384
- throw new Error(
1385
- `[luckystack:argv] unexpected positional argument(s): "${argv.slice(2).join(" ")}". Usage: npm run server -- <bundle[,bundle...]> [port]`
1386
- );
1387
- }
1388
- const bundles = argv[0] && argv[0].length > 0 ? [...new Set(argv[0].split(",").map((s) => s.trim()).filter(Boolean))] : [];
1389
- let port = null;
1390
- const portArg = argv[1];
1391
- if (portArg !== void 0) {
1392
- if (!PORT_PATTERN.test(portArg)) {
1393
- throw new Error(
1394
- `[luckystack:argv] port argument must be numeric, got: "${portArg}". Usage: npm run server -- <bundle[,bundle...]> [port]`
1395
- );
1396
- }
1397
- port = Number.parseInt(portArg, 10);
1398
- }
1399
- return { bundles, port };
1400
- };
1401
- var applyServerArgv = () => {
1402
- if (hasRun) return;
1403
- hasRun = true;
1404
- const result = parseServerArgv(process.argv.slice(2));
1405
- parsedBundles = result.bundles;
1406
- parsedPort = result.port;
1407
- if (parsedPort !== null) {
1408
- process.env.SERVER_PORT = String(parsedPort);
1409
- }
1410
- };
1411
- var getParsedBundles = () => parsedBundles;
1412
- var getParsedPort = () => parsedPort;
1413
-
1414
- // src/runtimeMapsLoader.ts
1415
1727
  var emptyRuntimeMaps = {
1416
1728
  apisObject: {},
1417
1729
  syncObject: {},
1418
1730
  functionsObject: {}
1419
1731
  };
1420
1732
  var isRuntimeMapRecord = (value) => Boolean(value) && typeof value === "object";
1421
- var normalizeGeneratedModule = (moduleValue) => {
1733
+ var normalizeGeneratedModule = (moduleValue, preset) => {
1422
1734
  const moduleRecord = moduleValue && typeof moduleValue === "object" ? moduleValue : {};
1423
1735
  const apiCandidate = moduleRecord.apis;
1424
1736
  const syncCandidate = moduleRecord.syncs;
1425
1737
  const functionCandidate = moduleRecord.functions;
1738
+ if (!isRuntimeMapRecord(apiCandidate) && !isRuntimeMapRecord(syncCandidate) && !isRuntimeMapRecord(functionCandidate)) {
1739
+ getLogger10().warn(
1740
+ `[luckystack:runtimeMaps] preset "${preset}" loaded but has no recognised shape (expected { apis, syncs, functions }). Verify that generateServerRequests has been run and the correct file is imported. All routes for this preset will return notFound.`
1741
+ );
1742
+ }
1426
1743
  return {
1427
1744
  apisObject: isRuntimeMapRecord(apiCandidate) ? apiCandidate : {},
1428
1745
  syncObject: isRuntimeMapRecord(syncCandidate) ? syncCandidate : {},
@@ -1484,19 +1801,19 @@ var createProdRuntimeMapsProvider = (options) => {
1484
1801
  let loadedAny = false;
1485
1802
  for (const { preset, mod } of loadedModules) {
1486
1803
  if (!mod) {
1487
- console.warn(
1804
+ getLogger10().warn(
1488
1805
  `[luckystack:runtimeMaps] preset "${preset}" failed to load \u2014 skipping. Calls owned by that preset will return notFound until the generated module resolves.`
1489
1806
  );
1490
1807
  continue;
1491
1808
  }
1492
1809
  loadedAny = true;
1493
- const normalized = normalizeGeneratedModule(mod);
1810
+ const normalized = normalizeGeneratedModule(mod, preset);
1494
1811
  mergeInto(merged.apisObject, normalized.apisObject, "api", preset, apiOrigin);
1495
1812
  mergeInto(merged.syncObject, normalized.syncObject, "sync", preset, syncOrigin);
1496
1813
  mergeInto(merged.functionsObject, normalized.functionsObject, "function", preset, functionOrigin);
1497
1814
  }
1498
1815
  if (!loadedAny) {
1499
- console.warn(
1816
+ getLogger10().warn(
1500
1817
  `[luckystack:runtimeMaps] no presets resolved (tried: ${presets.join(", ")}). Every api/sync request will return notFound until at least one generated module loads.`
1501
1818
  );
1502
1819
  return emptyRuntimeMaps;
@@ -1535,7 +1852,113 @@ var registerProdRuntimeMapsProvider = (options) => {
1535
1852
  return provider;
1536
1853
  };
1537
1854
 
1855
+ // src/stopServer.ts
1856
+ import {
1857
+ dispatchHook as dispatchHook7,
1858
+ flushErrorTrackers,
1859
+ getLogger as getLogger11,
1860
+ tryCatch as tryCatch8
1861
+ } from "@luckystack/core";
1862
+ var DEFAULT_SHUTDOWN_TIMEOUT_MS = 1e4;
1863
+ var withTimeout = async (label, timeoutMs, fn) => {
1864
+ let timer;
1865
+ const timeout = new Promise((resolve) => {
1866
+ timer = setTimeout(() => {
1867
+ getLogger11().warn(`[shutdown] step "${label}" exceeded ${String(timeoutMs)}ms \u2014 moving on`);
1868
+ resolve(false);
1869
+ }, timeoutMs);
1870
+ timer.unref();
1871
+ });
1872
+ const run = (async () => {
1873
+ const [error] = await tryCatch8(fn);
1874
+ if (error) getLogger11().warn(`[shutdown] step "${label}" failed: ${error.message}`);
1875
+ return true;
1876
+ })();
1877
+ const completed = await Promise.race([run, timeout]);
1878
+ if (timer) clearTimeout(timer);
1879
+ return completed;
1880
+ };
1881
+ var closeHttpServer = (httpServer) => new Promise((resolve, reject) => {
1882
+ httpServer.close((error) => {
1883
+ if (error) reject(error);
1884
+ else resolve();
1885
+ });
1886
+ });
1887
+ var closeIoServer = (ioServer) => new Promise((resolve, reject) => {
1888
+ void ioServer.close((error) => {
1889
+ if (error) reject(error);
1890
+ else resolve();
1891
+ });
1892
+ });
1893
+ var quitRedisClient = async (client) => {
1894
+ await client.quit();
1895
+ };
1896
+ var runGracefulShutdown = async (deps, options = {}) => {
1897
+ const { httpServer, ioServer, adapterClients } = deps;
1898
+ const reason = options.reason ?? "manual";
1899
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
1900
+ getLogger11().info(`[shutdown] graceful shutdown started (reason=${reason})`);
1901
+ const httpClosed = withTimeout("http-close", timeoutMs, () => closeHttpServer(httpServer));
1902
+ await withTimeout("preServerStop-hook", timeoutMs, async () => {
1903
+ await dispatchHook7("preServerStop", { reason, timeoutMs });
1904
+ });
1905
+ await withTimeout("flush-error-trackers", timeoutMs, () => flushErrorTrackers());
1906
+ await withTimeout("io-close", timeoutMs, () => closeIoServer(ioServer));
1907
+ await httpClosed;
1908
+ await withTimeout("redis-pub-quit", timeoutMs, () => quitRedisClient(adapterClients.pubClient));
1909
+ await withTimeout("redis-sub-quit", timeoutMs, () => quitRedisClient(adapterClients.subClient));
1910
+ getLogger11().info("[shutdown] graceful shutdown complete");
1911
+ };
1912
+
1538
1913
  // src/createServer.ts
1914
+ var initDevTools = async () => {
1915
+ const { initConsolelog } = await import("@luckystack/core");
1916
+ initConsolelog();
1917
+ const devkitModuleId = "@luckystack/devkit";
1918
+ const devkit = await import(devkitModuleId);
1919
+ await devkit.initializeAll();
1920
+ devkit.setupWatchers();
1921
+ process.once("SIGINT", () => process.exit(0));
1922
+ process.once("SIGTERM", () => process.exit(0));
1923
+ };
1924
+ var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((resolve, reject) => {
1925
+ const startPort = typeof port === "string" ? Number.parseInt(port, 10) : port;
1926
+ const autoIncrementEnv = (process.env.SERVER_PORT_AUTO_INCREMENT ?? "").toLowerCase();
1927
+ let autoIncrement;
1928
+ if (["0", "false"].includes(autoIncrementEnv)) autoIncrement = false;
1929
+ else if (["1", "true"].includes(autoIncrementEnv)) autoIncrement = true;
1930
+ else autoIncrement = !isProduction2;
1931
+ const tryListen = (attemptPort) => {
1932
+ const onError = (err) => {
1933
+ if (err.code !== "EADDRINUSE") {
1934
+ reject(err);
1935
+ return;
1936
+ }
1937
+ if (autoIncrement) {
1938
+ getLogger12().warn(
1939
+ `Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (auto-increment; set SERVER_PORT_AUTO_INCREMENT=0 to disable)`
1940
+ );
1941
+ tryListen(attemptPort + 1);
1942
+ return;
1943
+ }
1944
+ getLogger12().error(
1945
+ `Port ${String(attemptPort)} is already in use \u2014 the server did NOT start. Another \`npm run server\` is probably still running (stop it), or set SERVER_PORT to a free port, or set SERVER_PORT_AUTO_INCREMENT=1 to auto-pick the next free port.`
1946
+ );
1947
+ reject(err);
1948
+ };
1949
+ httpServer.once("error", onError);
1950
+ httpServer.listen(attemptPort, ip, () => {
1951
+ httpServer.off("error", onError);
1952
+ const config = getProjectConfig13();
1953
+ if (config.logging.socketStartup || config.logging.devLogs) {
1954
+ getLogger12().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
1955
+ }
1956
+ callback?.();
1957
+ resolve(httpServer);
1958
+ });
1959
+ };
1960
+ tryListen(startPort);
1961
+ });
1539
1962
  var createLuckyStackServer = async (options = {}) => {
1540
1963
  if (options.loadGeneratedMaps) {
1541
1964
  registerProdRuntimeMapsProvider({
@@ -1556,16 +1979,9 @@ var createLuckyStackServer = async (options = {}) => {
1556
1979
  port: typeof port === "string" ? Number.parseInt(port, 10) : port
1557
1980
  });
1558
1981
  if (enableDevTools) {
1559
- const { initConsolelog } = await import("@luckystack/core");
1560
- initConsolelog();
1561
- const devkitModuleId = "@luckystack/devkit";
1562
- const devkit = await import(devkitModuleId);
1563
- await devkit.initializeAll();
1564
- devkit.setupWatchers();
1565
- process.once("SIGINT", () => process.exit(0));
1566
- process.once("SIGTERM", () => process.exit(0));
1567
- }
1568
- const [bootUuidError] = await tryCatch7(() => writeBootUuid());
1982
+ await initDevTools();
1983
+ }
1984
+ const [bootUuidError] = await tryCatch9(() => writeBootUuid());
1569
1985
  if (bootUuidError) {
1570
1986
  throw new Error(
1571
1987
  "Failed to write the boot UUID to Redis. Check REDIS_HOST / REDIS_PORT / REDIS_USER / REDIS_PASSWORD and that Redis is reachable.",
@@ -1575,46 +1991,34 @@ var createLuckyStackServer = async (options = {}) => {
1575
1991
  const httpServer = http.createServer((req, res) => {
1576
1992
  void handleHttpRequest(req, res, options);
1577
1993
  });
1578
- const ioServer = loadSocket(httpServer, {
1994
+ httpServer.on("error", (err) => {
1995
+ if (err.code === "EADDRINUSE") return;
1996
+ getLogger12().error("[http-server] runtime error", err);
1997
+ });
1998
+ const { io: ioServer, adapterClients } = loadSocket(httpServer, {
1579
1999
  maxHttpBufferSize: options.maxHttpBufferSize
1580
2000
  });
1581
- const listen = (callback) => new Promise((resolve, reject) => {
1582
- const startPort = typeof port === "string" ? Number.parseInt(port, 10) : port;
1583
- const autoIncrement = ["1", "true"].includes(
1584
- (process.env.SERVER_PORT_AUTO_INCREMENT ?? "").toLowerCase()
1585
- );
1586
- const tryListen = (attemptPort) => {
1587
- const onError = (err) => {
1588
- if (err.code !== "EADDRINUSE") {
1589
- reject(err);
1590
- return;
1591
- }
1592
- if (autoIncrement) {
1593
- getLogger9().warn(
1594
- `Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (SERVER_PORT_AUTO_INCREMENT=1)`
1595
- );
1596
- tryListen(attemptPort + 1);
1597
- return;
1598
- }
1599
- getLogger9().error(
1600
- `Port ${String(attemptPort)} is already in use \u2014 the server did NOT start. Another \`npm run server\` is probably still running (stop it), or set SERVER_PORT to a free port, or set SERVER_PORT_AUTO_INCREMENT=1 to auto-pick the next free port.`
1601
- );
1602
- reject(err);
1603
- };
1604
- httpServer.once("error", onError);
1605
- httpServer.listen(attemptPort, ip, () => {
1606
- httpServer.off("error", onError);
1607
- const config = getProjectConfig10();
1608
- if (config.logging.socketStartup || config.logging.devLogs) {
1609
- getLogger9().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
1610
- }
1611
- callback?.();
1612
- resolve(httpServer);
1613
- });
2001
+ const listen = (callback) => listenLuckyStackServer(httpServer, ip, port, callback);
2002
+ let shutdownPromise = null;
2003
+ const stop = (stopOptions = {}) => {
2004
+ shutdownPromise ??= runGracefulShutdown({ httpServer, ioServer, adapterClients }, stopOptions);
2005
+ return shutdownPromise;
2006
+ };
2007
+ if (!enableDevTools) {
2008
+ const handleSignal = (reason) => {
2009
+ void (async () => {
2010
+ await stop({ reason });
2011
+ process.exit(0);
2012
+ })();
1614
2013
  };
1615
- tryListen(startPort);
1616
- });
1617
- return { httpServer, ioServer, listen };
2014
+ process.once("SIGTERM", () => {
2015
+ handleSignal("SIGTERM");
2016
+ });
2017
+ process.once("SIGINT", () => {
2018
+ handleSignal("SIGINT");
2019
+ });
2020
+ }
2021
+ return { httpServer, ioServer, listen, stop, close: stop };
1618
2022
  };
1619
2023
 
1620
2024
  // src/bootstrap.ts
@@ -1665,11 +2069,26 @@ var loadOverlayFolder = async (overlayRoot) => {
1665
2069
  }
1666
2070
  }
1667
2071
  };
2072
+ var importOptionalPackageRegisters = async () => {
2073
+ for (const pkg of OPTIONAL_PACKAGES) {
2074
+ const specifier = `@luckystack/${pkg}/register`;
2075
+ if (!canResolve(specifier)) continue;
2076
+ await importIfExistsSpecifier(specifier);
2077
+ }
2078
+ };
2079
+ var importIfExistsSpecifier = async (specifier) => {
2080
+ try {
2081
+ await import(specifier);
2082
+ } catch {
2083
+ }
2084
+ };
1668
2085
  var bootstrapLuckyStack = async (options = {}) => {
1669
2086
  const overlayRoot = options.overlayRoot ?? "luckystack";
1670
2087
  if (!options.skipOverlayLoad) {
2088
+ await importOptionalPackageRegisters();
1671
2089
  await loadOverlayFolder(overlayRoot);
1672
2090
  }
2091
+ await getLogin();
1673
2092
  const server = await createLuckyStackServer(options);
1674
2093
  return server;
1675
2094
  };