@luckystack/server 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 } 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);
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);
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,24 +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
- const baseLocation = config.app.publicUrl || "/";
390
- const callbackResult = await loginCallback(routePath, req, res, {
391
- defaultRedirectUrl: baseLocation
600
+ const publicOrigin = (config.app.publicUrl || "").replace(/\/+$/, "");
601
+ const loginRedirect = config.loginRedirectUrl || "/";
602
+ const baseLocation = /^https?:\/\//i.test(loginRedirect) ? loginRedirect : `${publicOrigin}${loginRedirect.startsWith("/") ? loginRedirect : `/${loginRedirect}`}`;
603
+ const callbackResult = await login.loginCallback(routePath, req, res, {
604
+ defaultRedirectUrl: baseLocation,
605
+ supersedeToken: token ?? void 0
392
606
  });
393
607
  if (!callbackResult) {
394
608
  res.writeHead(401, { "Content-Type": "text/plain" });
395
609
  res.end("Login failed");
396
610
  return true;
397
611
  }
398
- if (token) await deleteSession2(token);
399
- 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");
400
614
  const { token: newToken, redirectUrl } = callbackResult;
401
615
  if (config.session.basedToken) {
402
- const separator = redirectUrl.includes("?") ? "&" : "?";
403
- 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}` });
404
619
  } else {
405
620
  res.setHeader("Set-Cookie", `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`);
406
621
  res.writeHead(302, { Location: redirectUrl });
@@ -414,13 +629,13 @@ import {
414
629
  captureException,
415
630
  dispatchHook as dispatchHook3,
416
631
  extractTokenFromRequest,
417
- getLogger as getLogger3,
418
- tryCatch as tryCatch3
632
+ getLogger as getLogger4,
633
+ tryCatch as tryCatch4
419
634
  } from "@luckystack/core";
420
635
  import { handleHttpApiRequest } from "@luckystack/api";
421
636
 
422
637
  // src/sse.ts
423
- import { getProjectConfig as getProjectConfig7 } from "@luckystack/core";
638
+ import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
424
639
  var isExpectingEventStream = (acceptHeader) => {
425
640
  if (!acceptHeader) return false;
426
641
  const value = Array.isArray(acceptHeader) ? acceptHeader.join(",") : acceptHeader;
@@ -428,7 +643,7 @@ var isExpectingEventStream = (acceptHeader) => {
428
643
  };
429
644
  var queryRequestsStream = (queryString) => {
430
645
  if (!queryString) return false;
431
- const { queryParam, enabledValue } = getProjectConfig7().http.stream;
646
+ const { queryParam, enabledValue } = getProjectConfig8().http.stream;
432
647
  const params = new URLSearchParams(queryString);
433
648
  const value = params.get(queryParam);
434
649
  return value === enabledValue || value === "1";
@@ -444,7 +659,7 @@ var initSseResponse = (res) => {
444
659
  Connection: "keep-alive",
445
660
  "X-Accel-Buffering": "no"
446
661
  });
447
- const connectedComment = getProjectConfig7().http.stream.connectedComment;
662
+ const connectedComment = getProjectConfig8().http.stream.connectedComment;
448
663
  if (connectedComment) {
449
664
  res.write(`${connectedComment}
450
665
 
@@ -457,6 +672,8 @@ var sendSseEvent = ({
457
672
  data
458
673
  }) => {
459
674
  if (res.writableEnded) return;
675
+ const contentType = res.getHeader("Content-Type");
676
+ if (typeof contentType !== "string" || !contentType.includes("text/event-stream")) return;
460
677
  res.write(`event: ${event}
461
678
  `);
462
679
  res.write(`data: ${JSON.stringify(data)}
@@ -464,6 +681,15 @@ var sendSseEvent = ({
464
681
  `);
465
682
  };
466
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
+
467
693
  // src/httpRoutes/apiRoute.ts
468
694
  var handleApiRoute = async ({
469
695
  req,
@@ -479,11 +705,15 @@ var handleApiRoute = async ({
479
705
  let streamClosed = false;
480
706
  if (useHttpStream) {
481
707
  initSseResponse(res);
482
- req.on("close", () => {
708
+ const markClosed = () => {
483
709
  streamClosed = true;
484
- });
710
+ };
711
+ req.on("close", markClosed);
712
+ req.on("error", markClosed);
713
+ req.on("aborted", markClosed);
714
+ res.on("error", markClosed);
485
715
  }
486
- const [error, handled] = await tryCatch3(async () => {
716
+ const [error, handled] = await tryCatch4(async () => {
487
717
  const httpToken = extractTokenFromRequest(req);
488
718
  const apiName = routePath.slice(5);
489
719
  if (!apiName) {
@@ -505,11 +735,12 @@ var handleApiRoute = async ({
505
735
  }
506
736
  const apiData = typeof params === "object" ? { ...params } : {};
507
737
  delete apiData.stream;
738
+ const requesterIp = resolveRequesterIp(req);
508
739
  const result = await handleHttpApiRequest({
509
740
  name: apiName,
510
741
  data: apiData,
511
742
  token: httpToken,
512
- requesterIp: req.socket.remoteAddress ?? void 0,
743
+ requesterIp,
513
744
  xLanguageHeader: req.headers["x-language"],
514
745
  acceptLanguageHeader: req.headers["accept-language"],
515
746
  method,
@@ -531,7 +762,7 @@ var handleApiRoute = async ({
531
762
  if (!error) {
532
763
  return handled ?? true;
533
764
  }
534
- 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 });
535
766
  captureException(error, { routePath, method, requestId, source: "httpHandler.api" });
536
767
  void dispatchHook3("apiError", {
537
768
  route: routePath,
@@ -561,10 +792,9 @@ import {
561
792
  captureException as captureException2,
562
793
  dispatchHook as dispatchHook4,
563
794
  extractTokenFromRequest as extractTokenFromRequest2,
564
- getLogger as getLogger4,
565
- tryCatch as tryCatch4
795
+ getLogger as getLogger5,
796
+ tryCatch as tryCatch5
566
797
  } from "@luckystack/core";
567
- import { handleHttpSyncRequest } from "@luckystack/sync";
568
798
  var normalizeHttpSyncParams = (params) => {
569
799
  const source = params ?? {};
570
800
  const data = source.data && typeof source.data === "object" ? source.data : {};
@@ -585,15 +815,26 @@ var handleSyncRoute = async ({
585
815
  requestId
586
816
  }) => {
587
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
+ }
588
825
  const useHttpStream = shouldUseHttpStream({ acceptHeader: req.headers.accept, queryString });
589
826
  let streamClosed = false;
590
827
  if (useHttpStream) {
591
828
  initSseResponse(res);
592
- req.on("close", () => {
829
+ const markClosed = () => {
593
830
  streamClosed = true;
594
- });
831
+ };
832
+ req.on("close", markClosed);
833
+ req.on("error", markClosed);
834
+ req.on("aborted", markClosed);
835
+ res.on("error", markClosed);
595
836
  }
596
- const [error, handled] = await tryCatch4(async () => {
837
+ const [error, handled] = await tryCatch5(async () => {
597
838
  if (method !== "POST") {
598
839
  const response = {
599
840
  status: "error",
@@ -629,14 +870,15 @@ var handleSyncRoute = async ({
629
870
  return true;
630
871
  }
631
872
  const syncParams = normalizeHttpSyncParams(params);
632
- const result = await handleHttpSyncRequest({
873
+ const requesterIp = resolveRequesterIp(req);
874
+ const result = await sync.handleHttpSyncRequest({
633
875
  name: `sync/${syncName}`,
634
876
  cb: syncParams.cb,
635
877
  data: syncParams.data,
636
878
  receiver: syncParams.receiver,
637
879
  ignoreSelf: syncParams.ignoreSelf,
638
880
  token: httpToken,
639
- requesterIp: req.socket.remoteAddress ?? void 0,
881
+ requesterIp,
640
882
  xLanguageHeader: req.headers["x-language"],
641
883
  acceptLanguageHeader: req.headers["accept-language"],
642
884
  stream: useHttpStream ? (payload) => {
@@ -657,7 +899,7 @@ var handleSyncRoute = async ({
657
899
  if (!error) {
658
900
  return handled ?? true;
659
901
  }
660
- 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 });
661
903
  captureException2(error, { routePath, method, requestId, source: "httpHandler.sync" });
662
904
  void dispatchHook4("syncError", {
663
905
  route: routePath,
@@ -682,7 +924,7 @@ var handleSyncRoute = async ({
682
924
  };
683
925
 
684
926
  // src/httpRoutes/customRoutes.ts
685
- 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";
686
928
 
687
929
  // src/customRoutesRegistry.ts
688
930
  var handlers = [];
@@ -703,9 +945,9 @@ var clearCustomRoutes = () => {
703
945
 
704
946
  // src/httpRoutes/customRoutes.ts
705
947
  var runHandler = async (handler, req, res, ctx, source) => {
706
- const [error, handled] = await tryCatch5(() => handler(req, res, ctx));
948
+ const [error, handled] = await tryCatch6(() => handler(req, res, ctx));
707
949
  if (error) {
708
- getLogger5().error(`${source} threw`, error, { routePath: ctx.routePath, method: ctx.method });
950
+ getLogger6().error(`${source} threw`, error, { routePath: ctx.routePath, method: ctx.method });
709
951
  captureException3(error, { routePath: ctx.routePath, method: ctx.method, source });
710
952
  if (!res.writableEnded) {
711
953
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -754,6 +996,7 @@ var handleCustomRoutes = async ({
754
996
  // src/httpRoutes/staticRoutes.ts
755
997
  import path from "path";
756
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$)/;
757
1000
  var serveWithRewrittenUrl = async (serveFile, req, res, rewrittenUrl) => {
758
1001
  const originalUrl = req.url;
759
1002
  req.url = rewrittenUrl;
@@ -769,14 +1012,18 @@ var handleStaticAndSpaFallback = async ({
769
1012
  routePath,
770
1013
  options
771
1014
  }) => {
772
- 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/")) {
773
1021
  if (!options.serveFile) {
774
1022
  res.writeHead(404);
775
1023
  res.end("Not Found");
776
1024
  return true;
777
1025
  }
778
- const assetPath = routePath.slice(routePath.indexOf("/assets/"));
779
- await serveWithRewrittenUrl(options.serveFile, req, res, assetPath);
1026
+ await serveWithRewrittenUrl(options.serveFile, req, res, routePath);
780
1027
  return true;
781
1028
  }
782
1029
  if (KNOWN_STATIC_FILE_REGEX.test(routePath)) {
@@ -802,21 +1049,10 @@ var handleStaticAndSpaFallback = async ({
802
1049
  return true;
803
1050
  };
804
1051
 
805
- // src/originExemptRegistry.ts
806
- var exemptPaths = [];
807
- var registerOriginExemptPath = (matcher) => {
808
- exemptPaths.push(matcher);
809
- };
810
- var getOriginExemptPaths = () => exemptPaths;
811
- var clearOriginExemptPaths = () => {
812
- exemptPaths.length = 0;
813
- };
814
- var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
815
-
816
1052
  // src/httpHandler.ts
817
1053
  var buildSessionCookieOptions = (sessionExpiryDays, secure, http2) => `HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=${60 * 60 * 24 * sessionExpiryDays}; ${secure ? "Secure;" : ""}`;
818
1054
  var setSecurityHeaders = (req, res, origin) => {
819
- const { cors, securityHeaders } = getProjectConfig8().http;
1055
+ const { cors, securityHeaders } = getProjectConfig10().http;
820
1056
  res.setHeader("Access-Control-Allow-Origin", origin);
821
1057
  res.setHeader("Access-Control-Allow-Methods", cors.allowedMethods);
822
1058
  res.setHeader("Access-Control-Allow-Headers", cors.allowedHeaders);
@@ -830,15 +1066,16 @@ var setSecurityHeaders = (req, res, origin) => {
830
1066
  res.setHeader("X-Content-Type-Options", securityHeaders.contentTypeOptions);
831
1067
  const builder = getSecurityHeadersBuilder();
832
1068
  if (builder) {
833
- try {
1069
+ const [error] = tryCatchSync(() => {
834
1070
  const custom = builder(req);
835
1071
  if (custom) {
836
1072
  for (const [name, value] of Object.entries(custom)) {
837
1073
  res.setHeader(name, value);
838
1074
  }
839
1075
  }
840
- } catch (error) {
841
- 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 });
842
1079
  }
843
1080
  }
844
1081
  };
@@ -850,6 +1087,7 @@ var PRE_PARAMS_ROUTES = [
850
1087
  handleHealthRoute,
851
1088
  handleTestResetRoute,
852
1089
  handleAuthProvidersRoute,
1090
+ handleAuthLogoutRoute,
853
1091
  handlePreParamsCustomRoutes
854
1092
  ];
855
1093
  var POST_PARAMS_ROUTES = [
@@ -898,7 +1136,7 @@ var refreshSessionCookieIfPresent = async ({
898
1136
  }) => {
899
1137
  const hasTokenCookie = hasCookie(req.headers.cookie, sessionCookieName);
900
1138
  if (!hasTokenCookie || !token) return;
901
- const currentSession = await getSession3(token);
1139
+ const currentSession = await readSession3(token);
902
1140
  if (currentSession?.id) {
903
1141
  res.setHeader("Set-Cookie", `${sessionCookieName}=${token}; ${sessionCookieOptions}`);
904
1142
  }
@@ -916,25 +1154,28 @@ var parseRequestParams = async ({
916
1154
  if (res.writableEnded) return null;
917
1155
  if (params && typeof params === "object" && Object.keys(params).length > 0) {
918
1156
  if (shouldLogDev) {
919
- getLogger6().debug(`[${requestId}] ${method} ${routePath}`, {
1157
+ getLogger7().debug(`[${requestId}] ${method} ${routePath}`, {
920
1158
  params: sanitizeForLog(params)
921
1159
  });
922
1160
  }
923
1161
  } else {
924
1162
  if (shouldLogDev) {
925
- getLogger6().debug(`[${requestId}] ${method} ${routePath}`);
1163
+ getLogger7().debug(`[${requestId}] ${method} ${routePath}`);
926
1164
  }
927
1165
  params = {};
928
1166
  }
929
1167
  return params;
930
1168
  };
931
1169
  var handleHttpRequest = async (req, res, options) => {
932
- const config = getProjectConfig8();
1170
+ const config = getProjectConfig10();
933
1171
  const shouldLogDev = config.logging.devLogs;
934
1172
  const sessionCookieName = config.http.sessionCookieName;
935
1173
  const sessionCookieOptions = buildSessionCookieOptions(
936
1174
  config.session.expiryDays,
937
- 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),
938
1179
  config.http
939
1180
  );
940
1181
  const url = req.url ?? "/";
@@ -1015,6 +1256,8 @@ import {
1015
1256
  allowedOrigin as allowedOrigin2,
1016
1257
  applySocketMiddlewares,
1017
1258
  attachSocketRedisAdapter,
1259
+ formatRoomName,
1260
+ redis as redis3,
1018
1261
  setIoInstance,
1019
1262
  socketEventNames,
1020
1263
  buildJoinRoomResponseEventName,
@@ -1022,27 +1265,21 @@ import {
1022
1265
  buildGetJoinedRoomsResponseEventName,
1023
1266
  extractLanguageFromHeader,
1024
1267
  extractTokenFromSocket,
1025
- getLogger as getLogger7,
1026
- getProjectConfig as getProjectConfig9,
1268
+ getLogger as getLogger8,
1269
+ getProjectConfig as getProjectConfig11,
1027
1270
  dispatchHook as dispatchHook6,
1028
1271
  normalizeErrorResponse,
1029
- tryCatch as tryCatch6
1272
+ readSession as readSession4,
1273
+ writeSession,
1274
+ tryCatch as tryCatch7
1030
1275
  } from "@luckystack/core";
1031
1276
  import { handleApiRequest } from "@luckystack/api";
1032
- import { handleSyncRequest } from "@luckystack/sync";
1033
- import { getSession as getSession4, saveSession } from "@luckystack/login";
1034
- import {
1035
- initActivityBroadcaster,
1036
- socketConnected,
1037
- socketDisconnecting,
1038
- socketLeaveRoom
1039
- } from "@luckystack/presence";
1040
1277
  var sessionLocks = /* @__PURE__ */ new Map();
1041
1278
  var withSessionLock = async (token, fn) => {
1042
1279
  const prev = sessionLocks.get(token) ?? Promise.resolve();
1043
1280
  const next = prev.then(fn, fn);
1044
1281
  sessionLocks.set(token, next);
1045
- await tryCatch6(() => next);
1282
+ await tryCatch7(() => next);
1046
1283
  if (sessionLocks.get(token) === next) sessionLocks.delete(token);
1047
1284
  };
1048
1285
  var getVisibleSocketRooms = (socket, token) => {
@@ -1058,15 +1295,290 @@ var sanitizeSessionRoomKeys = (session) => {
1058
1295
  const { code: _legacyCode, codes: _legacyCodes, ...sanitizedSession } = session;
1059
1296
  return sanitizedSession;
1060
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
+ };
1061
1565
  var loadSocket = (httpServer, options = {}) => {
1062
- const config = getProjectConfig9();
1566
+ const config = getProjectConfig11();
1063
1567
  const shouldLogDev = config.logging.devLogs;
1064
1568
  const shouldLogSocketStartup = config.logging.socketStartup;
1065
1569
  const io = new SocketIOServer(httpServer, {
1066
1570
  cors: {
1067
1571
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
1068
1572
  origin: (origin, callback) => {
1069
- 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)) {
1070
1582
  callback(null, true);
1071
1583
  } else {
1072
1584
  callback(new Error("Not allowed by CORS"));
@@ -1080,225 +1592,53 @@ var loadSocket = (httpServer, options = {}) => {
1080
1592
  });
1081
1593
  setIoInstance(io);
1082
1594
  applySocketMiddlewares(io);
1083
- attachSocketRedisAdapter(io);
1595
+ const pubClient = redis3.duplicate();
1596
+ const subClient = redis3.duplicate();
1597
+ attachSocketRedisAdapter(io, { pubClient, subClient });
1084
1598
  if (shouldLogSocketStartup) {
1085
- getLogger7().info("SocketIO server initialized (redis adapter attached)");
1599
+ getLogger8().info("SocketIO server initialized (redis adapter attached)");
1086
1600
  }
1087
1601
  io.on(socketEventNames.connect, (socket) => {
1088
1602
  const token = extractTokenFromSocket(socket);
1089
1603
  const activityBroadcasterEnabled = config.socketActivityBroadcaster ?? false;
1090
1604
  const locationProviderEnabled = config.locationProviderEnabled ?? false;
1091
1605
  const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]) || void 0;
1092
- if (token) {
1093
- 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
+ })();
1094
1611
  }
1095
1612
  void dispatchHook6("onSocketConnect", {
1096
1613
  socketId: socket.id,
1097
1614
  token,
1098
1615
  ip: socket.handshake.address
1099
1616
  });
1100
- socket.on(socketEventNames.apiRequest, (msg) => {
1101
- void handleApiRequest({ msg, socket, token });
1102
- });
1103
- socket.on(socketEventNames.sync, (msg) => {
1104
- void handleSyncRequest({ msg, socket, token });
1105
- });
1106
- socket.on(socketEventNames.syncCancel, (data) => {
1107
- const cb = typeof data.cb === "string" ? data.cb : null;
1108
- if (!cb) return;
1109
- abortSyncByCb(socket.id, cb);
1110
- });
1111
- socket.on(socketEventNames.apiCancel, (data) => {
1112
- const responseIndex = data.responseIndex;
1113
- if (typeof responseIndex !== "number" && typeof responseIndex !== "string") return;
1114
- abortApiByResponseIndex(socket.id, responseIndex);
1115
- });
1116
- socket.on(socketEventNames.joinRoom, (data) => {
1117
- const group = typeof data.group === "string" ? data.group.trim() : "";
1118
- const responseIndex = data.responseIndex;
1119
- if (typeof responseIndex !== "number") return;
1120
- if (!token) {
1121
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1122
- response: { status: "error", errorCode: "auth.required" },
1123
- preferredLocale
1124
- }));
1125
- return;
1126
- }
1127
- if (!group) {
1128
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1129
- response: { status: "error", errorCode: "room.invalid" },
1130
- preferredLocale
1131
- }));
1132
- return;
1133
- }
1134
- void withSessionLock(token, async () => {
1135
- const session = await getSession4(token);
1136
- if (!session) {
1137
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1138
- response: { status: "error", errorCode: "session.notFound" },
1139
- preferredLocale
1140
- }));
1141
- return;
1142
- }
1143
- const preResult = await dispatchHook6("preRoomJoin", { token, room: group });
1144
- if (preResult.stopped) {
1145
- socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1146
- response: {
1147
- status: "error",
1148
- errorCode: preResult.signal.errorCode || "room.joinBlocked"
1149
- },
1150
- preferredLocale,
1151
- userLanguage: session.language
1152
- }));
1153
- return;
1154
- }
1155
- const existingRoomCodes = getSessionRoomCodes(session);
1156
- const nextRoomCodes = [.../* @__PURE__ */ new Set([...existingRoomCodes, group])];
1157
- await socket.join(group);
1158
- const sanitizedSession = sanitizeSessionRoomKeys(session);
1159
- await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1160
- const visibleRooms = getVisibleSocketRooms(socket, token);
1161
- socket.emit(buildJoinRoomResponseEventName(responseIndex), { rooms: visibleRooms });
1162
- if (shouldLogDev) {
1163
- getLogger7().debug(`Socket ${socket.id} joined group ${group}`);
1164
- }
1165
- void dispatchHook6("postRoomJoin", { token, room: group, allRooms: visibleRooms });
1166
- });
1167
- });
1168
- socket.on(socketEventNames.leaveRoom, (data) => {
1169
- const group = typeof data.group === "string" ? data.group.trim() : "";
1170
- const responseIndex = data.responseIndex;
1171
- if (typeof responseIndex !== "number") return;
1172
- if (!token) {
1173
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1174
- response: { status: "error", errorCode: "auth.required" },
1175
- preferredLocale
1176
- }));
1177
- return;
1178
- }
1179
- if (!group) {
1180
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1181
- response: { status: "error", errorCode: "room.invalid" },
1182
- preferredLocale
1183
- }));
1184
- return;
1185
- }
1186
- void withSessionLock(token, async () => {
1187
- const session = await getSession4(token);
1188
- if (!session) {
1189
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1190
- response: { status: "error", errorCode: "session.notFound" },
1191
- preferredLocale
1192
- }));
1193
- return;
1194
- }
1195
- const preResult = await dispatchHook6("preRoomLeave", { token, room: group });
1196
- if (preResult.stopped) {
1197
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
1198
- response: {
1199
- status: "error",
1200
- errorCode: preResult.signal.errorCode || "room.leaveBlocked"
1201
- },
1202
- preferredLocale,
1203
- userLanguage: session.language
1204
- }));
1205
- return;
1206
- }
1207
- const existingRoomCodes = getSessionRoomCodes(session);
1208
- const nextRoomCodes = existingRoomCodes.filter((roomCode) => roomCode !== group);
1209
- await socket.leave(group);
1210
- const sanitizedSession = sanitizeSessionRoomKeys(session);
1211
- await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1212
- const visibleRooms = getVisibleSocketRooms(socket, token);
1213
- socket.emit(buildLeaveRoomResponseEventName(responseIndex), { rooms: visibleRooms });
1214
- if (shouldLogDev) {
1215
- getLogger7().debug(`Socket ${socket.id} left group ${group}`);
1216
- }
1217
- void dispatchHook6("postRoomLeave", { token, room: group, allRooms: visibleRooms });
1218
- });
1219
- });
1220
- socket.on(socketEventNames.getJoinedRooms, (data) => {
1221
- const responseIndex = data.responseIndex;
1222
- if (typeof responseIndex !== "number") return;
1223
- if (!token) {
1224
- socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
1225
- ...normalizeErrorResponse({
1226
- response: { status: "error", errorCode: "auth.required" },
1227
- preferredLocale
1228
- }),
1229
- rooms: []
1230
- });
1231
- return;
1232
- }
1233
- socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
1234
- rooms: getVisibleSocketRooms(socket, token)
1235
- });
1236
- });
1237
- socket.on(socketEventNames.disconnect, (reason) => {
1238
- abortAllForSocket(socket.id);
1239
- void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
1240
- if (activityBroadcasterEnabled && token) {
1241
- void socketDisconnecting({ token, socket, reason });
1242
- } else {
1243
- if (!token) return;
1244
- if (shouldLogDev) {
1245
- getLogger7().debug(`user disconnected`, { reason });
1246
- }
1247
- }
1248
- });
1249
- socket.on(
1250
- socketEventNames.updateLocation,
1251
- (newLocation) => {
1252
- if (!token) return;
1253
- if (!locationProviderEnabled) return;
1254
- if (shouldLogDev) {
1255
- getLogger7().debug("updating location", { pathName: newLocation.pathName });
1256
- }
1257
- void withSessionLock(token, async () => {
1258
- let returnedUser = null;
1259
- if (activityBroadcasterEnabled) {
1260
- returnedUser = await socketLeaveRoom({ token, socket, newPath: newLocation.pathName });
1261
- }
1262
- const user = returnedUser ?? await getSession4(token);
1263
- if (!user) return;
1264
- const extendedUser = user;
1265
- const oldLocation = extendedUser.location;
1266
- extendedUser.location = newLocation;
1267
- await saveSession(token, user);
1268
- void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
1269
- });
1270
- }
1271
- );
1272
- if (activityBroadcasterEnabled && token) {
1273
- initActivityBroadcaster({ socket, token });
1274
- }
1275
- if (token) {
1276
- void (async () => {
1277
- const [rejoinError, codes] = await tryCatch6(async () => {
1278
- await socket.join(token);
1279
- const session = await getSession4(token);
1280
- const roomCodes = session ? getSessionRoomCodes(session) : [];
1281
- for (const roomCode of roomCodes) {
1282
- await socket.join(roomCode);
1283
- }
1284
- return roomCodes;
1285
- });
1286
- if (rejoinError) {
1287
- getLogger7().warn(`socket room rejoin failed for ${socket.id}`, { error: rejoinError.message });
1288
- return;
1289
- }
1290
- if (shouldLogDev) {
1291
- getLogger7().debug(`socket ${socket.id} (re)joined rooms: ${(codes ?? []).join(", ") || "(none)"}`);
1292
- }
1293
- })();
1294
- }
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);
1295
1633
  });
1296
- return io;
1634
+ return { io, adapterClients: { pubClient, subClient } };
1297
1635
  };
1298
1636
 
1299
1637
  // src/verifyBootstrap.ts
1300
1638
  import {
1301
- getLogger as getLogger8,
1639
+ collectSynchronizedEnvKeys,
1640
+ getLogger as getLogger9,
1641
+ getProjectConfig as getProjectConfig12,
1302
1642
  isDeployConfigRegistered,
1303
1643
  isLocalizedNormalizerRegistered,
1304
1644
  isProjectConfigRegistered,
@@ -1325,8 +1665,12 @@ var verifyBootstrap = async (requirements = {}) => {
1325
1665
  }
1326
1666
  }
1327
1667
  if (requirements.requireOAuthProviders) {
1328
- const { getOAuthProviders: getOAuthProviders3 } = await import("@luckystack/login");
1329
- 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) {
1330
1674
  missing.push(
1331
1675
  "OAuth providers \u2014 call `registerOAuthProviders([...])` from `luckystack/login/oauthProviders.ts` (or skip this check if your app uses credentials only)."
1332
1676
  );
@@ -1338,7 +1682,7 @@ var verifyBootstrap = async (requirements = {}) => {
1338
1682
  "RuntimeMapsProvider \u2014 call `registerRuntimeMapsProvider({...})` from `server/prod/runtimeMaps.ts`. Without it, every api/sync request returns notFound."
1339
1683
  );
1340
1684
  } else {
1341
- getLogger8().warn(
1685
+ getLogger9().warn(
1342
1686
  "[LuckyStack] No RuntimeMapsProvider registered \u2014 api/sync requests will resolve to empty maps. Devkit hot-reload usually registers one automatically."
1343
1687
  );
1344
1688
  }
@@ -1349,11 +1693,20 @@ var verifyBootstrap = async (requirements = {}) => {
1349
1693
  "LocalizedNormalizer \u2014 call `registerLocalizedNormalizer({...})` from your bootstrap. Without it, error response messages will be the raw errorCode (no i18n)."
1350
1694
  );
1351
1695
  } else {
1352
- getLogger8().warn(
1696
+ getLogger9().warn(
1353
1697
  "[LuckyStack] No LocalizedNormalizer registered \u2014 error messages will pass through as the raw errorCode."
1354
1698
  );
1355
1699
  }
1356
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
+ }
1357
1710
  if (missing.length === 0) return;
1358
1711
  const detail = missing.map((line, idx) => ` ${idx + 1}. ${line}`).join("\n");
1359
1712
  throw new Error(
@@ -1368,58 +1721,25 @@ var verifyBootstrap = async (requirements = {}) => {
1368
1721
 
1369
1722
  // src/runtimeMapsLoader.ts
1370
1723
  import {
1724
+ getLogger as getLogger10,
1371
1725
  registerRuntimeMapsProvider
1372
1726
  } from "@luckystack/core";
1373
-
1374
- // src/argv.ts
1375
- var parsedBundles = [];
1376
- var parsedPort = null;
1377
- var hasRun = false;
1378
- var PORT_PATTERN = /^\d+$/;
1379
- var parseServerArgv = (argv) => {
1380
- if (argv.length > 2) {
1381
- throw new Error(
1382
- `[luckystack:argv] unexpected positional argument(s): "${argv.slice(2).join(" ")}". Usage: npm run server -- <bundle[,bundle...]> [port]`
1383
- );
1384
- }
1385
- const bundles = argv[0] && argv[0].length > 0 ? [...new Set(argv[0].split(",").map((s) => s.trim()).filter(Boolean))] : [];
1386
- let port = null;
1387
- const portArg = argv[1];
1388
- if (portArg !== void 0) {
1389
- if (!PORT_PATTERN.test(portArg)) {
1390
- throw new Error(
1391
- `[luckystack:argv] port argument must be numeric, got: "${portArg}". Usage: npm run server -- <bundle[,bundle...]> [port]`
1392
- );
1393
- }
1394
- port = Number.parseInt(portArg, 10);
1395
- }
1396
- return { bundles, port };
1397
- };
1398
- var applyServerArgv = () => {
1399
- if (hasRun) return;
1400
- hasRun = true;
1401
- const result = parseServerArgv(process.argv.slice(2));
1402
- parsedBundles = result.bundles;
1403
- parsedPort = result.port;
1404
- if (parsedPort !== null) {
1405
- process.env.SERVER_PORT = String(parsedPort);
1406
- }
1407
- };
1408
- var getParsedBundles = () => parsedBundles;
1409
- var getParsedPort = () => parsedPort;
1410
-
1411
- // src/runtimeMapsLoader.ts
1412
1727
  var emptyRuntimeMaps = {
1413
1728
  apisObject: {},
1414
1729
  syncObject: {},
1415
1730
  functionsObject: {}
1416
1731
  };
1417
1732
  var isRuntimeMapRecord = (value) => Boolean(value) && typeof value === "object";
1418
- var normalizeGeneratedModule = (moduleValue) => {
1733
+ var normalizeGeneratedModule = (moduleValue, preset) => {
1419
1734
  const moduleRecord = moduleValue && typeof moduleValue === "object" ? moduleValue : {};
1420
1735
  const apiCandidate = moduleRecord.apis;
1421
1736
  const syncCandidate = moduleRecord.syncs;
1422
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
+ }
1423
1743
  return {
1424
1744
  apisObject: isRuntimeMapRecord(apiCandidate) ? apiCandidate : {},
1425
1745
  syncObject: isRuntimeMapRecord(syncCandidate) ? syncCandidate : {},
@@ -1481,19 +1801,19 @@ var createProdRuntimeMapsProvider = (options) => {
1481
1801
  let loadedAny = false;
1482
1802
  for (const { preset, mod } of loadedModules) {
1483
1803
  if (!mod) {
1484
- console.warn(
1804
+ getLogger10().warn(
1485
1805
  `[luckystack:runtimeMaps] preset "${preset}" failed to load \u2014 skipping. Calls owned by that preset will return notFound until the generated module resolves.`
1486
1806
  );
1487
1807
  continue;
1488
1808
  }
1489
1809
  loadedAny = true;
1490
- const normalized = normalizeGeneratedModule(mod);
1810
+ const normalized = normalizeGeneratedModule(mod, preset);
1491
1811
  mergeInto(merged.apisObject, normalized.apisObject, "api", preset, apiOrigin);
1492
1812
  mergeInto(merged.syncObject, normalized.syncObject, "sync", preset, syncOrigin);
1493
1813
  mergeInto(merged.functionsObject, normalized.functionsObject, "function", preset, functionOrigin);
1494
1814
  }
1495
1815
  if (!loadedAny) {
1496
- console.warn(
1816
+ getLogger10().warn(
1497
1817
  `[luckystack:runtimeMaps] no presets resolved (tried: ${presets.join(", ")}). Every api/sync request will return notFound until at least one generated module loads.`
1498
1818
  );
1499
1819
  return emptyRuntimeMaps;
@@ -1532,7 +1852,111 @@ var registerProdRuntimeMapsProvider = (options) => {
1532
1852
  return provider;
1533
1853
  };
1534
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
+
1535
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 autoIncrement = ["1", "true"].includes(
1927
+ (process.env.SERVER_PORT_AUTO_INCREMENT ?? "").toLowerCase()
1928
+ );
1929
+ const tryListen = (attemptPort) => {
1930
+ const onError = (err) => {
1931
+ if (err.code !== "EADDRINUSE") {
1932
+ reject(err);
1933
+ return;
1934
+ }
1935
+ if (autoIncrement) {
1936
+ getLogger12().warn(
1937
+ `Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (SERVER_PORT_AUTO_INCREMENT=1)`
1938
+ );
1939
+ tryListen(attemptPort + 1);
1940
+ return;
1941
+ }
1942
+ getLogger12().error(
1943
+ `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.`
1944
+ );
1945
+ reject(err);
1946
+ };
1947
+ httpServer.once("error", onError);
1948
+ httpServer.listen(attemptPort, ip, () => {
1949
+ httpServer.off("error", onError);
1950
+ const config = getProjectConfig13();
1951
+ if (config.logging.socketStartup || config.logging.devLogs) {
1952
+ getLogger12().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
1953
+ }
1954
+ callback?.();
1955
+ resolve(httpServer);
1956
+ });
1957
+ };
1958
+ tryListen(startPort);
1959
+ });
1536
1960
  var createLuckyStackServer = async (options = {}) => {
1537
1961
  if (options.loadGeneratedMaps) {
1538
1962
  registerProdRuntimeMapsProvider({
@@ -1553,16 +1977,9 @@ var createLuckyStackServer = async (options = {}) => {
1553
1977
  port: typeof port === "string" ? Number.parseInt(port, 10) : port
1554
1978
  });
1555
1979
  if (enableDevTools) {
1556
- const { initConsolelog } = await import("@luckystack/core");
1557
- initConsolelog();
1558
- const devkitModuleId = "@luckystack/devkit";
1559
- const devkit = await import(devkitModuleId);
1560
- await devkit.initializeAll();
1561
- devkit.setupWatchers();
1562
- process.once("SIGINT", () => process.exit(0));
1563
- process.once("SIGTERM", () => process.exit(0));
1564
- }
1565
- const [bootUuidError] = await tryCatch7(() => writeBootUuid());
1980
+ await initDevTools();
1981
+ }
1982
+ const [bootUuidError] = await tryCatch9(() => writeBootUuid());
1566
1983
  if (bootUuidError) {
1567
1984
  throw new Error(
1568
1985
  "Failed to write the boot UUID to Redis. Check REDIS_HOST / REDIS_PORT / REDIS_USER / REDIS_PASSWORD and that Redis is reachable.",
@@ -1572,21 +1989,34 @@ var createLuckyStackServer = async (options = {}) => {
1572
1989
  const httpServer = http.createServer((req, res) => {
1573
1990
  void handleHttpRequest(req, res, options);
1574
1991
  });
1575
- const ioServer = loadSocket(httpServer, {
1992
+ httpServer.on("error", (err) => {
1993
+ if (err.code === "EADDRINUSE") return;
1994
+ getLogger12().error("[http-server] runtime error", err);
1995
+ });
1996
+ const { io: ioServer, adapterClients } = loadSocket(httpServer, {
1576
1997
  maxHttpBufferSize: options.maxHttpBufferSize
1577
1998
  });
1578
- const listen = (callback) => new Promise((resolve) => {
1579
- const portValue = typeof port === "string" ? Number.parseInt(port, 10) : port;
1580
- httpServer.listen(portValue, ip, () => {
1581
- const config = getProjectConfig10();
1582
- if (config.logging.socketStartup || config.logging.devLogs) {
1583
- getLogger9().info(`Server is running on http://${ip}:${String(port)}/`);
1584
- }
1585
- callback?.();
1586
- resolve(httpServer);
1999
+ const listen = (callback) => listenLuckyStackServer(httpServer, ip, port, callback);
2000
+ let shutdownPromise = null;
2001
+ const stop = (stopOptions = {}) => {
2002
+ shutdownPromise ??= runGracefulShutdown({ httpServer, ioServer, adapterClients }, stopOptions);
2003
+ return shutdownPromise;
2004
+ };
2005
+ if (!enableDevTools) {
2006
+ const handleSignal = (reason) => {
2007
+ void (async () => {
2008
+ await stop({ reason });
2009
+ process.exit(0);
2010
+ })();
2011
+ };
2012
+ process.once("SIGTERM", () => {
2013
+ handleSignal("SIGTERM");
1587
2014
  });
1588
- });
1589
- return { httpServer, ioServer, listen };
2015
+ process.once("SIGINT", () => {
2016
+ handleSignal("SIGINT");
2017
+ });
2018
+ }
2019
+ return { httpServer, ioServer, listen, stop, close: stop };
1590
2020
  };
1591
2021
 
1592
2022
  // src/bootstrap.ts
@@ -1637,11 +2067,26 @@ var loadOverlayFolder = async (overlayRoot) => {
1637
2067
  }
1638
2068
  }
1639
2069
  };
2070
+ var importOptionalPackageRegisters = async () => {
2071
+ for (const pkg of OPTIONAL_PACKAGES) {
2072
+ const specifier = `@luckystack/${pkg}/register`;
2073
+ if (!canResolve(specifier)) continue;
2074
+ await importIfExistsSpecifier(specifier);
2075
+ }
2076
+ };
2077
+ var importIfExistsSpecifier = async (specifier) => {
2078
+ try {
2079
+ await import(specifier);
2080
+ } catch {
2081
+ }
2082
+ };
1640
2083
  var bootstrapLuckyStack = async (options = {}) => {
1641
2084
  const overlayRoot = options.overlayRoot ?? "luckystack";
1642
2085
  if (!options.skipOverlayLoad) {
2086
+ await importOptionalPackageRegisters();
1643
2087
  await loadOverlayFolder(overlayRoot);
1644
2088
  }
2089
+ await getLogin();
1645
2090
  const server = await createLuckyStackServer(options);
1646
2091
  return server;
1647
2092
  };