@luckystack/server 0.14.0 → 0.15.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
@@ -9,7 +9,7 @@ import {
9
9
 
10
10
  // src/createServer.ts
11
11
  import http from "http";
12
- import { registerBindAddress, registerBoundAddress, writeBootUuid, startBootUuidHeartbeat, getLogger as getLogger14, getProjectConfig as getProjectConfig15, tryCatch as tryCatch11, tryCatchSync as tryCatchSync3, resolveRuntimeMode, dispatchHook as dispatchHook8 } from "@luckystack/core";
12
+ import { registerBindAddress, registerBoundAddress, writeBootUuid, startBootUuidHeartbeat, getLogger as getLogger14, getProjectConfig as getProjectConfig16, tryCatch as tryCatch12, tryCatchSync as tryCatchSync3, resolveRuntimeMode, dispatchHook as dispatchHook8 } from "@luckystack/core";
13
13
 
14
14
  // src/httpHandler.ts
15
15
  import { randomUUID } from "crypto";
@@ -17,9 +17,10 @@ import {
17
17
  allowedOrigin,
18
18
  dispatchHook as dispatchHook5,
19
19
  extractTokenFromRequest as extractTokenFromRequest3,
20
+ getCsrfConfig as getCsrfConfig3,
20
21
  getLogger as getLogger7,
21
22
  getParams,
22
- getProjectConfig as getProjectConfig12,
23
+ getProjectConfig as getProjectConfig13,
23
24
  hasCookie,
24
25
  normalizeOrigin,
25
26
  readSession as readSession3,
@@ -494,18 +495,42 @@ import {
494
495
  checkRateLimit,
495
496
  dispatchHook as dispatchHook2,
496
497
  getLogger,
497
- getProjectConfig as getProjectConfig5,
498
+ getProjectConfig as getProjectConfig6,
498
499
  resolveClientIp,
499
500
  resolveDevCallbackUrl
500
501
  } from "@luckystack/core";
501
502
 
503
+ // src/httpRoutes/clientSession.ts
504
+ var toClientSession = (session) => {
505
+ if (typeof session !== "object" || session === null) return session;
506
+ const { token: _token, csrfToken: _csrfToken, ...clientSession } = session;
507
+ return clientSession;
508
+ };
509
+
502
510
  // src/httpRoutes/sessionCookie.ts
503
- import { isProductionRuntime } from "@luckystack/core";
511
+ import { applyCookiePrefixConstraints, getProjectConfig as getProjectConfig5, isProductionRuntime } from "@luckystack/core";
504
512
  var resolveCookieSecure = (sessionCookieSecure, secureEnv) => {
505
513
  if (sessionCookieSecure !== void 0) return sessionCookieSecure;
506
514
  if (secureEnv === "true") return true;
507
515
  return isProductionRuntime();
508
516
  };
517
+ var resolveSessionCookieName = () => {
518
+ const { http: http2 } = getProjectConfig5();
519
+ return applyCookiePrefixConstraints(http2.sessionCookieName, http2.sessionCookiePrefix).name;
520
+ };
521
+ var buildSessionCookieOptions = (maxAgeSeconds) => {
522
+ const { http: http2 } = getProjectConfig5();
523
+ const constraints = applyCookiePrefixConstraints(
524
+ http2.sessionCookieName,
525
+ http2.sessionCookiePrefix,
526
+ resolveCookieSecure(http2.sessionCookieSecure, process.env.SECURE)
527
+ );
528
+ const path4 = constraints.forcePathRoot ? "/" : http2.sessionCookiePath;
529
+ const domain = http2.sessionCookieDomain && !constraints.forbidDomain ? `Domain=${http2.sessionCookieDomain}; ` : "";
530
+ return `HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${path4}; Max-Age=${maxAgeSeconds}; ${domain}${constraints.secure ? "Secure;" : ""}`;
531
+ };
532
+ var buildSessionCookie = (token, cookieOptions) => `${resolveSessionCookieName()}=${token}; ${cookieOptions}`;
533
+ var buildClearedSessionCookie = () => buildSessionCookie("", buildSessionCookieOptions(0));
509
534
 
510
535
  // src/httpRoutes/authApiRoute.ts
511
536
  var parseSessionBasedTokenHeader = (headerValue) => {
@@ -528,6 +553,7 @@ var handleAuthApiRoute = async ({
528
553
  req,
529
554
  res,
530
555
  routePath,
556
+ method,
531
557
  token,
532
558
  params,
533
559
  sessionCookieOptions
@@ -539,8 +565,7 @@ var handleAuthApiRoute = async ({
539
565
  res.end(JSON.stringify({ status: false, reason: "auth.disabled" }));
540
566
  return true;
541
567
  }
542
- const config = getProjectConfig5();
543
- const sessionCookieName = config.http.sessionCookieName;
568
+ const config = getProjectConfig6();
544
569
  const shouldLogDev = config.logging.devLogs;
545
570
  const providerName = routePath.split("/")[3];
546
571
  const provider = login.getOAuthProviders().find((p) => p.name === providerName);
@@ -624,6 +649,12 @@ var handleAuthApiRoute = async ({
624
649
  res.end();
625
650
  return true;
626
651
  }
652
+ if (method !== "POST") {
653
+ res.statusCode = 405;
654
+ res.setHeader("Allow", "POST");
655
+ res.end();
656
+ return true;
657
+ }
627
658
  const rateLimiting = config.rateLimiting;
628
659
  const requesterIp = resolveClientIp({
629
660
  rawAddress: req.socket.remoteAddress,
@@ -685,21 +716,21 @@ var handleAuthApiRoute = async ({
685
716
  if (useSessionBasedToken) {
686
717
  res.setHeader("X-Session-Token", result.newToken);
687
718
  } else {
688
- res.setHeader("Set-Cookie", `${sessionCookieName}=${result.newToken}; ${sessionCookieOptions}`);
719
+ res.setHeader("Set-Cookie", buildSessionCookie(result.newToken, sessionCookieOptions));
689
720
  }
690
721
  }
691
722
  res.setHeader("content-type", "application/json; charset=utf-8");
692
723
  res.end(JSON.stringify({
693
724
  status: result.status,
694
725
  reason: result.reason,
695
- session: result.session,
726
+ session: toClientSession(result.session),
696
727
  authenticated: Boolean(result.newToken)
697
728
  }));
698
729
  return true;
699
730
  };
700
731
 
701
732
  // src/httpRoutes/authSecondFactorRoutes.ts
702
- import { checkRateLimit as checkRateLimit2, getProjectConfig as getProjectConfig6, resolveClientIp as resolveClientIp2 } from "@luckystack/core";
733
+ import { checkRateLimit as checkRateLimit2, getProjectConfig as getProjectConfig7, resolveClientIp as resolveClientIp2 } from "@luckystack/core";
703
734
  var json = (ctx, statusCode, body) => {
704
735
  ctx.res.statusCode = statusCode;
705
736
  ctx.res.setHeader("content-type", "application/json; charset=utf-8");
@@ -713,7 +744,7 @@ var methodNotAllowed = (ctx) => {
713
744
  return true;
714
745
  };
715
746
  var requesterIpOf = (ctx) => {
716
- const config = getProjectConfig6();
747
+ const config = getProjectConfig7();
717
748
  return resolveClientIp2({
718
749
  rawAddress: ctx.req.socket.remoteAddress,
719
750
  headers: ctx.req.headers,
@@ -744,7 +775,7 @@ var sendLoginResult = async (ctx, login, result) => {
744
775
  }
745
776
  if (result.newToken) {
746
777
  if (ctx.token) await login.deleteSession(ctx.token, { skipSocketLogout: true });
747
- const config = getProjectConfig6();
778
+ const config = getProjectConfig7();
748
779
  const headerValue = ctx.req.headers["x-session-based-token"];
749
780
  const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue;
750
781
  const requestedSessionMode = raw === "true" ? true : raw === "false" ? false : null;
@@ -752,13 +783,13 @@ var sendLoginResult = async (ctx, login, result) => {
752
783
  if (useSessionBasedToken) {
753
784
  ctx.res.setHeader("X-Session-Token", result.newToken);
754
785
  } else {
755
- ctx.res.setHeader("Set-Cookie", `${config.http.sessionCookieName}=${result.newToken}; ${ctx.sessionCookieOptions}`);
786
+ ctx.res.setHeader("Set-Cookie", buildSessionCookie(result.newToken, ctx.sessionCookieOptions));
756
787
  }
757
788
  }
758
789
  return json(ctx, 200, {
759
790
  status: true,
760
791
  reason: result.reason,
761
- session: result.session,
792
+ session: toClientSession(result.session),
762
793
  authenticated: Boolean(result.newToken)
763
794
  });
764
795
  };
@@ -841,7 +872,7 @@ var handleAuthTwoFactorRoute = async (ctx) => {
841
872
  };
842
873
 
843
874
  // src/httpRoutes/authLogoutRoute.ts
844
- import { getLogger as getLogger2, getProjectConfig as getProjectConfig7, tryCatch as tryCatch3 } from "@luckystack/core";
875
+ import { getLogger as getLogger2, getProjectConfig as getProjectConfig8, tryCatch as tryCatch3 } from "@luckystack/core";
845
876
  var warnedNonStrictSameSite = false;
846
877
  var handleAuthLogoutRoute = async ({ res, routePath, method, token }) => {
847
878
  if (routePath !== "/auth/logout") return false;
@@ -861,18 +892,14 @@ var handleAuthLogoutRoute = async ({ res, routePath, method, token }) => {
861
892
  }
862
893
  }
863
894
  }
864
- const http2 = getProjectConfig7().http;
895
+ const http2 = getProjectConfig8().http;
865
896
  if (http2.sessionCookieSameSite !== "Strict" && !warnedNonStrictSameSite) {
866
897
  warnedNonStrictSameSite = true;
867
898
  getLogger2().warn(
868
899
  `/auth/logout is exempt from CSRF middleware and relies on SameSite=Strict. Current sessionCookieSameSite="${http2.sessionCookieSameSite}" weakens this protection.`
869
900
  );
870
901
  }
871
- const secure = resolveCookieSecure(http2.sessionCookieSecure, process.env.SECURE);
872
- res.setHeader(
873
- "Set-Cookie",
874
- `${http2.sessionCookieName}=; HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=0; ${secure ? "Secure;" : ""}`
875
- );
902
+ res.setHeader("Set-Cookie", buildClearedSessionCookie());
876
903
  res.statusCode = 200;
877
904
  res.setHeader("Content-Type", "application/json");
878
905
  res.end(JSON.stringify({ status: "success", result: true }));
@@ -880,19 +907,19 @@ var handleAuthLogoutRoute = async ({ res, routePath, method, token }) => {
880
907
  };
881
908
 
882
909
  // src/httpRoutes/authProvidersRoute.ts
883
- import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
910
+ import { getProjectConfig as getProjectConfig9 } from "@luckystack/core";
884
911
  var handleAuthProvidersRoute = async ({ req, res, routePath }) => {
885
912
  if (routePath !== "/auth/providers" || req.method !== "GET") return false;
886
913
  const login = await getLogin();
887
914
  const providers = login ? login.getOAuthProviders().map((provider) => provider.name) : [];
888
- const emailCodeLogin = Boolean(login) && getProjectConfig8().auth.emailCodeLogin;
915
+ const emailCodeLogin = Boolean(login) && getProjectConfig9().auth.emailCodeLogin;
889
916
  res.setHeader("Content-Type", "application/json");
890
917
  res.end(JSON.stringify({ providers, emailCodeLogin }));
891
918
  return true;
892
919
  };
893
920
 
894
921
  // src/httpRoutes/authCallbackRoute.ts
895
- import { getLogger as getLogger3, getProjectConfig as getProjectConfig9 } from "@luckystack/core";
922
+ import { getLogger as getLogger3, getProjectConfig as getProjectConfig10 } from "@luckystack/core";
896
923
  var handleAuthCallbackRoute = async ({
897
924
  req,
898
925
  res,
@@ -907,8 +934,7 @@ var handleAuthCallbackRoute = async ({
907
934
  res.end("Auth is not enabled");
908
935
  return true;
909
936
  }
910
- const config = getProjectConfig9();
911
- const sessionCookieName = config.http.sessionCookieName;
937
+ const config = getProjectConfig10();
912
938
  const shouldLogDev = config.logging.devLogs;
913
939
  const publicOrigin = (config.app.publicUrl || "").replace(/\/+$/, "");
914
940
  const loginRedirect = config.loginRedirectUrl || "/";
@@ -933,7 +959,7 @@ var handleAuthCallbackRoute = async ({
933
959
  res.setHeader("Set-Cookie", clearStateCookie);
934
960
  res.writeHead(302, { Location: `${baseUrl}#token=${newToken}` });
935
961
  } else {
936
- res.setHeader("Set-Cookie", [clearStateCookie, `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`]);
962
+ res.setHeader("Set-Cookie", [clearStateCookie, buildSessionCookie(newToken, sessionCookieOptions)]);
937
963
  res.writeHead(302, { Location: redirectUrl });
938
964
  }
939
965
  res.end();
@@ -952,7 +978,7 @@ import {
952
978
  import { handleHttpApiRequest } from "@luckystack/api";
953
979
 
954
980
  // src/sse.ts
955
- import { getProjectConfig as getProjectConfig10 } from "@luckystack/core";
981
+ import { getProjectConfig as getProjectConfig11 } from "@luckystack/core";
956
982
  var isExpectingEventStream = (acceptHeader) => {
957
983
  if (!acceptHeader) return false;
958
984
  const value = Array.isArray(acceptHeader) ? acceptHeader.join(",") : acceptHeader;
@@ -960,7 +986,7 @@ var isExpectingEventStream = (acceptHeader) => {
960
986
  };
961
987
  var queryRequestsStream = (queryString) => {
962
988
  if (!queryString) return false;
963
- const { queryParam, enabledValue } = getProjectConfig10().http.stream;
989
+ const { queryParam, enabledValue } = getProjectConfig11().http.stream;
964
990
  const params = new URLSearchParams(queryString);
965
991
  const value = params.get(queryParam);
966
992
  return value === enabledValue || value === "1";
@@ -976,7 +1002,7 @@ var initSseResponse = (res) => {
976
1002
  Connection: "keep-alive",
977
1003
  "X-Accel-Buffering": "no"
978
1004
  });
979
- const connectedComment = getProjectConfig10().http.stream.connectedComment;
1005
+ const connectedComment = getProjectConfig11().http.stream.connectedComment;
980
1006
  if (connectedComment) {
981
1007
  res.write(`${connectedComment}
982
1008
 
@@ -999,10 +1025,10 @@ var sendSseEvent = ({
999
1025
  };
1000
1026
 
1001
1027
  // src/httpRoutes/resolveRequesterIp.ts
1002
- import { getProjectConfig as getProjectConfig11, resolveClientIp as resolveClientIp3 } from "@luckystack/core";
1028
+ import { getProjectConfig as getProjectConfig12, resolveClientIp as resolveClientIp3 } from "@luckystack/core";
1003
1029
  var resolveRequesterIp = (req) => {
1004
- const trustProxy = getProjectConfig11().http.trustProxy;
1005
- const trustedProxyHopCount = getProjectConfig11().http.trustedProxyHopCount;
1030
+ const trustProxy = getProjectConfig12().http.trustProxy;
1031
+ const trustedProxyHopCount = getProjectConfig12().http.trustedProxyHopCount;
1006
1032
  const rawRemoteAddress = req.socket.remoteAddress;
1007
1033
  return rawRemoteAddress || trustProxy && (req.headers["x-forwarded-for"] || req.headers["x-real-ip"]) ? resolveClientIp3({ rawAddress: rawRemoteAddress, headers: req.headers, trustProxy, trustedProxyHopCount }) : void 0;
1008
1034
  };
@@ -1065,7 +1091,7 @@ var handleApiRoute = async ({
1065
1091
  return true;
1066
1092
  }
1067
1093
  let apiData = typeof params === "object" ? { ...params } : {};
1068
- delete apiData.stream;
1094
+ if (method === "GET") delete apiData.stream;
1069
1095
  const routedData = apiData.__luckystack_data;
1070
1096
  if (typeof routedData === "string") {
1071
1097
  const [parseError, parsed] = tryCatchSync(() => JSON.parse(routedData));
@@ -1399,7 +1425,7 @@ var handleStaticAndSpaFallback = async ({
1399
1425
  res.end("Not Found");
1400
1426
  return true;
1401
1427
  }
1402
- await options.serveFile(req, res);
1428
+ await serveWithRewrittenUrl(options.serveFile, req, res, routePath);
1403
1429
  return true;
1404
1430
  }
1405
1431
  if (path.extname(routePath)) {
@@ -1417,9 +1443,17 @@ var handleStaticAndSpaFallback = async ({
1417
1443
  };
1418
1444
 
1419
1445
  // src/httpHandler.ts
1420
- var buildSessionCookieOptions = (sessionExpiryDays, secure, http2) => `HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=${60 * 60 * 24 * sessionExpiryDays}; ${secure ? "Secure;" : ""}`;
1446
+ var buildHookSafeHeaders = (headers) => {
1447
+ const csrfHeaderName = getCsrfConfig3().headerName.toLowerCase();
1448
+ const safeHeaders = {};
1449
+ for (const [k, v] of Object.entries(headers)) {
1450
+ if (k === "authorization" || k === "proxy-authorization" || k === "cookie" || k === "set-cookie" || k === "x-csrf-token" || k === csrfHeaderName || k === "x-test-reset-token" || k === "x-session-based-token") continue;
1451
+ safeHeaders[k] = Array.isArray(v) ? v.join(", ") : v ?? "";
1452
+ }
1453
+ return safeHeaders;
1454
+ };
1421
1455
  var setSecurityHeaders = (req, res, origin) => {
1422
- const { cors, securityHeaders } = getProjectConfig12().http;
1456
+ const { cors, securityHeaders } = getProjectConfig13().http;
1423
1457
  res.setHeader("Access-Control-Allow-Origin", origin);
1424
1458
  res.setHeader("Access-Control-Allow-Methods", cors.allowedMethods);
1425
1459
  res.setHeader("Access-Control-Allow-Headers", cors.allowedHeaders);
@@ -1503,14 +1537,13 @@ var refreshSessionCookieIfPresent = async ({
1503
1537
  req,
1504
1538
  res,
1505
1539
  token,
1506
- sessionCookieName,
1507
1540
  sessionCookieOptions
1508
1541
  }) => {
1509
- const hasTokenCookie = hasCookie(req.headers.cookie, sessionCookieName);
1542
+ const hasTokenCookie = hasCookie(req.headers.cookie, resolveSessionCookieName());
1510
1543
  if (!hasTokenCookie || !token) return;
1511
1544
  const currentSession = await readSession3(token);
1512
1545
  if (currentSession?.id) {
1513
- res.setHeader("Set-Cookie", `${sessionCookieName}=${token}; ${sessionCookieOptions}`);
1546
+ res.setHeader("Set-Cookie", buildSessionCookie(token, sessionCookieOptions));
1514
1547
  }
1515
1548
  };
1516
1549
  var parseRequestParams = async ({
@@ -1539,17 +1572,9 @@ var parseRequestParams = async ({
1539
1572
  return params;
1540
1573
  };
1541
1574
  var handleHttpRequestInner = async (req, res, options) => {
1542
- const config = getProjectConfig12();
1575
+ const config = getProjectConfig13();
1543
1576
  const shouldLogDev = config.logging.devLogs;
1544
- const sessionCookieName = config.http.sessionCookieName;
1545
- const sessionCookieOptions = buildSessionCookieOptions(
1546
- config.session.expiryDays,
1547
- //? Honor the explicit `http.sessionCookieSecure` override (CORE-39), else the
1548
- //? `SECURE` env flag — shared with the OAuth state cookie via
1549
- //? `resolveCookieSecure` so the two can never drift (WAVE4).
1550
- resolveCookieSecure(config.http.sessionCookieSecure, process.env.SECURE),
1551
- config.http
1552
- );
1577
+ const sessionCookieOptions = buildSessionCookieOptions(60 * 60 * 24 * config.session.expiryDays);
1553
1578
  const url = req.url ?? "/";
1554
1579
  const [routePathRaw, queryStringRaw] = url.split("?");
1555
1580
  const [decodeError, decodedPath] = tryCatchSync2(() => decodeURIComponent(routePathRaw ?? "/"));
@@ -1568,11 +1593,7 @@ var handleHttpRequestInner = async (req, res, options) => {
1568
1593
  const rawRequestId = Array.isArray(incomingRequestId) ? incomingRequestId[0] : incomingRequestId;
1569
1594
  const requestId = rawRequestId && /^[a-zA-Z0-9-]{1,128}$/.test(rawRequestId) ? rawRequestId : randomUUID();
1570
1595
  res.setHeader("X-Request-Id", requestId);
1571
- const safeHeaders = {};
1572
- for (const [k, v] of Object.entries(req.headers)) {
1573
- if (k === "authorization" || k === "cookie" || k === "set-cookie" || k === "x-csrf-token" || k === "x-test-reset-token" || k === "x-session-based-token") continue;
1574
- safeHeaders[k] = Array.isArray(v) ? v.join(", ") : v ?? "";
1575
- }
1596
+ const safeHeaders = buildHookSafeHeaders(req.headers);
1576
1597
  const preHttpResult = await dispatchHook5("preHttpRequest", {
1577
1598
  method: req.method?.toUpperCase() ?? "GET",
1578
1599
  url: req.url ?? "/",
@@ -1599,7 +1620,7 @@ var handleHttpRequestInner = async (req, res, options) => {
1599
1620
  return;
1600
1621
  }
1601
1622
  const token = extractTokenFromRequest3(req);
1602
- await refreshSessionCookieIfPresent({ req, res, token, sessionCookieName, sessionCookieOptions });
1623
+ await refreshSessionCookieIfPresent({ req, res, token, sessionCookieOptions });
1603
1624
  const csrfRejected = await enforceCsrfOnStateChangingRequest({ req, res, routePath, token, requestId });
1604
1625
  if (csrfRejected) return;
1605
1626
  const baseCtx = {
@@ -1655,10 +1676,11 @@ import {
1655
1676
  extractLanguageFromHeader,
1656
1677
  extractTokenFromSocket,
1657
1678
  getLogger as getLogger8,
1658
- getProjectConfig as getProjectConfig13,
1679
+ getProjectConfig as getProjectConfig14,
1659
1680
  dispatchHook as dispatchHook6,
1660
1681
  normalizeErrorResponse,
1661
1682
  readSession as readSession4,
1683
+ resolveClientIp as resolveClientIp4,
1662
1684
  writeSession,
1663
1685
  tryCatch as tryCatch8
1664
1686
  } from "@luckystack/core";
@@ -1671,6 +1693,7 @@ var withSessionLock = async (token, fn) => {
1671
1693
  await tryCatch8(() => next);
1672
1694
  if (sessionLocks.get(token) === next) sessionLocks.delete(token);
1673
1695
  };
1696
+ var SYNC_ALL_RECEIVER = "all";
1674
1697
  var getVisibleSocketRooms = (socket, token) => {
1675
1698
  return [...socket.rooms].filter((room) => typeof room === "string").filter((room) => room !== socket.id).filter((room) => !token || room !== token);
1676
1699
  };
@@ -1680,6 +1703,7 @@ var getSessionRoomCodes = (session) => {
1680
1703
  ) : [];
1681
1704
  return [...new Set(roomCodes)];
1682
1705
  };
1706
+ var asPayload = (data) => typeof data === "object" && data !== null ? data : {};
1683
1707
  var sanitizeSessionRoomKeys = (session) => {
1684
1708
  const { code: _legacyCode, codes: _legacyCodes, ...sanitizedSession } = session;
1685
1709
  return sanitizedSession;
@@ -1741,7 +1765,16 @@ var executeRoomMutation = async (opts) => {
1741
1765
  const physicalRoom = formatRoomName(group, { purpose: "broadcast", userId: session.id });
1742
1766
  const nextRoomCodes = await mutate(socket, physicalRoom, group, existingRoomCodes, session.id);
1743
1767
  const sanitizedSession = sanitizeSessionRoomKeys(session);
1744
- await writeSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1768
+ const saved = await writeSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes }, void 0, { onlyIfExists: true });
1769
+ if (!saved.ok) {
1770
+ if (preHook === "preRoomJoin") await socket.leave(physicalRoom);
1771
+ socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
1772
+ response: { status: "error", errorCode: saved.errorCode ?? "session.notFound" },
1773
+ preferredLocale,
1774
+ userLanguage: session.language
1775
+ }));
1776
+ return;
1777
+ }
1745
1778
  const visibleRooms = getVisibleSocketRooms(socket, token);
1746
1779
  socket.emit(buildResponseEventName(responseIndex), { rooms: visibleRooms });
1747
1780
  if (shouldLogDev) {
@@ -1766,12 +1799,12 @@ var registerApiAndSyncEvents = (ctx) => {
1766
1799
  var registerCancellationEvents = (ctx) => {
1767
1800
  const { socket } = ctx;
1768
1801
  socket.on(socketEventNames.syncCancel, (data) => {
1769
- const cb = typeof data.cb === "string" ? data.cb : null;
1770
- if (!cb) return;
1802
+ const { cb } = asPayload(data);
1803
+ if (typeof cb !== "string" || !cb) return;
1771
1804
  abortSyncByCb(socket.id, cb);
1772
1805
  });
1773
1806
  socket.on(socketEventNames.apiCancel, (data) => {
1774
- const responseIndex = data.responseIndex;
1807
+ const { responseIndex } = asPayload(data);
1775
1808
  if (typeof responseIndex !== "number" && typeof responseIndex !== "string") return;
1776
1809
  abortApiByResponseIndex(socket.id, responseIndex);
1777
1810
  });
@@ -1779,10 +1812,18 @@ var registerCancellationEvents = (ctx) => {
1779
1812
  var registerRoomEvents = (ctx) => {
1780
1813
  const { socket, token, preferredLocale, shouldLogDev } = ctx;
1781
1814
  socket.on(socketEventNames.joinRoom, (data) => {
1782
- const group = typeof data.group === "string" ? data.group.trim() : "";
1783
- const responseIndex = data.responseIndex;
1815
+ const payload = asPayload(data);
1816
+ const group = typeof payload.group === "string" ? payload.group.trim() : "";
1817
+ const responseIndex = payload.responseIndex;
1784
1818
  if (!validateRoomRequest(socket, responseIndex, token, group, preferredLocale, buildJoinRoomResponseEventName)) return;
1785
1819
  if (!token) return;
1820
+ if (group === SYNC_ALL_RECEIVER) {
1821
+ socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
1822
+ response: { status: "error", errorCode: "room.invalid" },
1823
+ preferredLocale
1824
+ }));
1825
+ return;
1826
+ }
1786
1827
  void withSessionLock(token, async () => {
1787
1828
  await executeRoomMutation({
1788
1829
  socket,
@@ -1797,7 +1838,7 @@ var registerRoomEvents = (ctx) => {
1797
1838
  blockedErrorCode: "room.joinBlocked",
1798
1839
  logVerb: "joined",
1799
1840
  mutate: async (sock, physicalRoom, rawGroup, existingCodes, userId) => {
1800
- const maxRooms = getProjectConfig13().socket.maxRoomsPerSession;
1841
+ const maxRooms = getProjectConfig14().socket.maxRoomsPerSession;
1801
1842
  let kept = existingCodes;
1802
1843
  if (maxRooms !== false && maxRooms > 0 && !existingCodes.includes(rawGroup)) {
1803
1844
  while (kept.length >= maxRooms) {
@@ -1814,8 +1855,9 @@ var registerRoomEvents = (ctx) => {
1814
1855
  });
1815
1856
  });
1816
1857
  socket.on(socketEventNames.leaveRoom, (data) => {
1817
- const group = typeof data.group === "string" ? data.group.trim() : "";
1818
- const responseIndex = data.responseIndex;
1858
+ const payload = asPayload(data);
1859
+ const group = typeof payload.group === "string" ? payload.group.trim() : "";
1860
+ const responseIndex = payload.responseIndex;
1819
1861
  if (!validateRoomRequest(socket, responseIndex, token, group, preferredLocale, buildLeaveRoomResponseEventName)) return;
1820
1862
  if (!token) return;
1821
1863
  void withSessionLock(token, async () => {
@@ -1840,7 +1882,7 @@ var registerRoomEvents = (ctx) => {
1840
1882
  });
1841
1883
  });
1842
1884
  socket.on(socketEventNames.getJoinedRooms, (data) => {
1843
- const responseIndex = data.responseIndex;
1885
+ const { responseIndex } = asPayload(data);
1844
1886
  if (typeof responseIndex !== "number") return;
1845
1887
  if (!token) {
1846
1888
  socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
@@ -1884,17 +1926,22 @@ var registerUpdateLocationEvent = (ctx) => {
1884
1926
  const { socket, token, activityBroadcasterEnabled, locationProviderEnabled, shouldLogDev } = ctx;
1885
1927
  socket.on(
1886
1928
  socketEventNames.updateLocation,
1887
- (newLocation) => {
1929
+ (data) => {
1888
1930
  if (!token) return;
1889
1931
  if (!locationProviderEnabled) return;
1890
- if (typeof newLocation.pathName !== "string" || !newLocation.pathName.startsWith("/") || newLocation.pathName.length > 2048 || newLocation.pathName.includes("\0")) return;
1891
- if (newLocation.searchParams !== void 0) {
1892
- if (typeof newLocation.searchParams !== "object" || Array.isArray(newLocation.searchParams)) return;
1893
- const entries = Object.entries(newLocation.searchParams);
1932
+ const { pathName, searchParams } = asPayload(data);
1933
+ if (typeof pathName !== "string" || !pathName.startsWith("/") || pathName.length > 2048 || pathName.includes("\0")) return;
1934
+ const newLocation = { pathName };
1935
+ if (searchParams !== void 0) {
1936
+ if (typeof searchParams !== "object" || searchParams === null || Array.isArray(searchParams)) return;
1937
+ const entries = Object.entries(searchParams);
1894
1938
  if (entries.length > 50) return;
1939
+ const validated = {};
1895
1940
  for (const [k, v] of entries) {
1896
- if (typeof k !== "string" || k.length > 256 || typeof v !== "string" || v.length > 1024) return;
1941
+ if (k.length > 256 || typeof v !== "string" || v.length > 1024) return;
1942
+ validated[k] = v;
1897
1943
  }
1944
+ newLocation.searchParams = validated;
1898
1945
  }
1899
1946
  if (shouldLogDev) {
1900
1947
  getLogger8().debug("updating location", { pathName: newLocation.pathName });
@@ -1912,7 +1959,8 @@ var registerUpdateLocationEvent = (ctx) => {
1912
1959
  const extendedUser = user;
1913
1960
  const oldLocation = extendedUser.location;
1914
1961
  const sanitizedUser = sanitizeSessionRoomKeys({ ...extendedUser, location: newLocation });
1915
- await writeSession(token, sanitizedUser);
1962
+ const saved = await writeSession(token, sanitizedUser, void 0, { onlyIfExists: true });
1963
+ if (!saved.ok) return;
1916
1964
  void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
1917
1965
  });
1918
1966
  }
@@ -1955,6 +2003,7 @@ var rejoinPersistedRooms = (ctx) => {
1955
2003
  const roomCodes = session ? getSessionRoomCodes(session) : [];
1956
2004
  const userId = session?.id ?? null;
1957
2005
  for (const roomCode of roomCodes) {
2006
+ if (roomCode === SYNC_ALL_RECEIVER) continue;
1958
2007
  await socket.join(formatRoomName(roomCode, { purpose: "broadcast", userId }));
1959
2008
  }
1960
2009
  return roomCodes;
@@ -1969,7 +2018,7 @@ var rejoinPersistedRooms = (ctx) => {
1969
2018
  })();
1970
2019
  };
1971
2020
  var loadSocket = (httpServer, options = {}) => {
1972
- const config = getProjectConfig13();
2021
+ const config = getProjectConfig14();
1973
2022
  const shouldLogDev = config.logging.devLogs;
1974
2023
  const shouldLogSocketStartup = config.logging.socketStartup;
1975
2024
  const io = new SocketIOServer(httpServer, {
@@ -2014,7 +2063,14 @@ var loadSocket = (httpServer, options = {}) => {
2014
2063
  void dispatchHook6("onSocketConnect", {
2015
2064
  socketId: socket.id,
2016
2065
  token,
2017
- ip: socket.handshake.address
2066
+ //? Same resolution as the api/sync socket paths, so behind a trusted proxy
2067
+ //? the hook sees the client and not the proxy address.
2068
+ ip: resolveClientIp4({
2069
+ rawAddress: socket.handshake.address,
2070
+ headers: socket.handshake.headers,
2071
+ trustProxy: config.http.trustProxy,
2072
+ trustedProxyHopCount: config.http.trustedProxyHopCount
2073
+ })
2018
2074
  });
2019
2075
  const ctx = {
2020
2076
  socket,
@@ -2040,7 +2096,7 @@ var loadSocket = (httpServer, options = {}) => {
2040
2096
  import {
2041
2097
  collectSynchronizedEnvKeys,
2042
2098
  getLogger as getLogger9,
2043
- getProjectConfig as getProjectConfig14,
2099
+ getProjectConfig as getProjectConfig15,
2044
2100
  isDeployConfigRegistered,
2045
2101
  isLocalizedNormalizerRegistered,
2046
2102
  isProjectConfigRegistered,
@@ -2107,7 +2163,7 @@ var verifyBootstrap = async (requirements = {}) => {
2107
2163
  }
2108
2164
  if (isProjectConfigRegistered()) {
2109
2165
  const synchronizedKeyCount = collectSynchronizedEnvKeys().length;
2110
- const healthHashMode = getProjectConfig14().http.healthHash.mode;
2166
+ const healthHashMode = getProjectConfig15().http.healthHash.mode;
2111
2167
  if (synchronizedKeyCount > 0 && healthHashMode === "plain") {
2112
2168
  getLogger9().warn(
2113
2169
  `[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.`
@@ -2115,7 +2171,7 @@ var verifyBootstrap = async (requirements = {}) => {
2115
2171
  }
2116
2172
  }
2117
2173
  if (isProjectConfigRegistered()) {
2118
- const cfg = getProjectConfig14();
2174
+ const cfg = getProjectConfig15();
2119
2175
  if (!cfg.session.basedToken && cfg.http.sessionCookieSameSite !== "Strict") {
2120
2176
  getLogger9().warn(
2121
2177
  `[LuckyStack] SECURITY: http.sessionCookieSameSite is '${cfg.http.sessionCookieSameSite}', not 'Strict', but the CSRF-exempt auth-bootstrap endpoints (/auth/api/credentials, /auth/callback/*) rely on SameSite=Strict to block cross-site login-CSRF. Use 'Strict', or add your own CSRF/origin check on those routes.`
@@ -2138,7 +2194,8 @@ var verifyBootstrap = async (requirements = {}) => {
2138
2194
  import {
2139
2195
  getLogger as getLogger10,
2140
2196
  registerRuntimeMapsProvider,
2141
- isProductionRuntime as isProductionRuntime3
2197
+ isProductionRuntime as isProductionRuntime3,
2198
+ tryCatch as tryCatch9
2142
2199
  } from "@luckystack/core";
2143
2200
  var emptyRuntimeMaps = {
2144
2201
  apisObject: {},
@@ -2201,6 +2258,7 @@ var mergeInto = (target, source, kind, fromPreset, keyOrigin) => {
2201
2258
  var isProduction = () => isProductionRuntime3();
2202
2259
  var createProdRuntimeMapsProvider = (options) => {
2203
2260
  let prodMapsPromise = null;
2261
+ const reportedFailures = /* @__PURE__ */ new Set();
2204
2262
  let devkitModulePromise = null;
2205
2263
  const getDevkit = async () => {
2206
2264
  devkitModulePromise ??= import("@luckystack/devkit");
@@ -2208,6 +2266,7 @@ var createProdRuntimeMapsProvider = (options) => {
2208
2266
  };
2209
2267
  const loadProdRuntimeMaps = async () => {
2210
2268
  if (prodMapsPromise) return await prodMapsPromise;
2269
+ const loadState = { anyPresetFailed: false };
2211
2270
  prodMapsPromise = (async () => {
2212
2271
  const presets = resolvePresets(options);
2213
2272
  const merged = {
@@ -2219,19 +2278,29 @@ var createProdRuntimeMapsProvider = (options) => {
2219
2278
  const syncOrigin = /* @__PURE__ */ new Map();
2220
2279
  const functionOrigin = /* @__PURE__ */ new Map();
2221
2280
  const loadedModules = await Promise.all(
2222
- presets.map(async (preset) => ({
2223
- preset,
2224
- mod: await options.loadGenerated(preset).catch(() => null)
2225
- }))
2281
+ presets.map(async (preset) => {
2282
+ const [loadError, mod] = await tryCatch9(() => options.loadGenerated(preset));
2283
+ return { preset, mod, loadError };
2284
+ })
2226
2285
  );
2227
2286
  let loadedAny = false;
2228
- for (const { preset, mod } of loadedModules) {
2287
+ let newFailure = false;
2288
+ for (const { preset, mod, loadError } of loadedModules) {
2229
2289
  if (!mod) {
2230
- getLogger10().warn(
2231
- `[luckystack:runtimeMaps] preset "${preset}" failed to load \u2014 skipping. Calls owned by that preset will return notFound until the generated module resolves.`
2232
- );
2290
+ loadState.anyPresetFailed = true;
2291
+ if (!reportedFailures.has(preset)) {
2292
+ reportedFailures.add(preset);
2293
+ newFailure = true;
2294
+ getLogger10().error(
2295
+ `[luckystack:runtimeMaps] preset "${preset}" failed to load \u2014 skipping. Calls owned by that preset will return notFound until the generated module resolves (retried on the next request).`,
2296
+ loadError ?? new Error("loadGenerated returned no module")
2297
+ );
2298
+ }
2233
2299
  continue;
2234
2300
  }
2301
+ if (reportedFailures.delete(preset)) {
2302
+ getLogger10().info(`[luckystack:runtimeMaps] preset "${preset}" loaded after an earlier failure.`);
2303
+ }
2235
2304
  loadedAny = true;
2236
2305
  const normalized = normalizeGeneratedModule(mod, preset);
2237
2306
  mergeInto(merged.apisObject, normalized.apisObject, "api", preset, apiOrigin);
@@ -2239,14 +2308,16 @@ var createProdRuntimeMapsProvider = (options) => {
2239
2308
  mergeInto(merged.functionsObject, normalized.functionsObject, "function", preset, functionOrigin);
2240
2309
  }
2241
2310
  if (!loadedAny) {
2242
- getLogger10().warn(
2311
+ if (newFailure || presets.length === 0) getLogger10().warn(
2243
2312
  `[luckystack:runtimeMaps] no presets resolved (tried: ${presets.join(", ")}). Every api/sync request will return notFound until at least one generated module loads.`
2244
2313
  );
2245
2314
  return emptyRuntimeMaps;
2246
2315
  }
2247
2316
  return merged;
2248
2317
  })();
2249
- return await prodMapsPromise;
2318
+ const result = await prodMapsPromise;
2319
+ if (loadState.anyPresetFailed) prodMapsPromise = null;
2320
+ return result;
2250
2321
  };
2251
2322
  const getRuntimeApiMaps = async () => {
2252
2323
  if (!isProduction()) {
@@ -2283,7 +2354,7 @@ import {
2283
2354
  dispatchHook as dispatchHook7,
2284
2355
  flushErrorTrackers,
2285
2356
  getLogger as getLogger11,
2286
- tryCatch as tryCatch9
2357
+ tryCatch as tryCatch10
2287
2358
  } from "@luckystack/core";
2288
2359
  var DEFAULT_SHUTDOWN_TIMEOUT_MS = 1e4;
2289
2360
  var withTimeout = async (label, timeoutMs, fn) => {
@@ -2296,7 +2367,7 @@ var withTimeout = async (label, timeoutMs, fn) => {
2296
2367
  timer.unref();
2297
2368
  });
2298
2369
  const run = (async () => {
2299
- const [error] = await tryCatch9(fn);
2370
+ const [error] = await tryCatch10(fn);
2300
2371
  if (error) getLogger11().warn(`[shutdown] step "${label}" failed: ${error.message}`);
2301
2372
  return true;
2302
2373
  })();
@@ -2312,7 +2383,7 @@ var closeHttpServer = (httpServer) => new Promise((resolve, reject) => {
2312
2383
  });
2313
2384
  var closeIoServer = (ioServer) => new Promise((resolve, reject) => {
2314
2385
  void ioServer.close((error) => {
2315
- if (error) reject(error);
2386
+ if (error && error.code !== "ERR_SERVER_NOT_RUNNING") reject(error);
2316
2387
  else resolve();
2317
2388
  });
2318
2389
  });
@@ -2366,9 +2437,9 @@ var clearDevServerInfo = () => {
2366
2437
  };
2367
2438
 
2368
2439
  // src/afterListen.ts
2369
- import { getLogger as getLogger13, tryCatch as tryCatch10 } from "@luckystack/core";
2440
+ import { getLogger as getLogger13, tryCatch as tryCatch11 } from "@luckystack/core";
2370
2441
  var runAfterListenTask = async (task, { fatal = false, label = "post-listen task" } = {}) => {
2371
- const [error] = await tryCatch10(async () => task());
2442
+ const [error] = await tryCatch11(async () => task());
2372
2443
  if (!error) return;
2373
2444
  if (fatal) throw error;
2374
2445
  getLogger13().error(
@@ -2403,7 +2474,7 @@ var initDevTools = async () => {
2403
2474
  );
2404
2475
  return;
2405
2476
  }
2406
- const [devkitError] = await tryCatch11(async () => {
2477
+ const [devkitError] = await tryCatch12(async () => {
2407
2478
  const devkit = await import(devkitModuleId);
2408
2479
  await devkit.initializeAll();
2409
2480
  devkit.setupWatchers();
@@ -2428,6 +2499,7 @@ var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((re
2428
2499
  else autoIncrement = !production;
2429
2500
  const tryListen = (attemptPort) => {
2430
2501
  const onError = (err) => {
2502
+ httpServer.off("listening", onListening);
2431
2503
  if (err.code !== "EADDRINUSE") {
2432
2504
  reject(err);
2433
2505
  return;
@@ -2452,8 +2524,7 @@ var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((re
2452
2524
  );
2453
2525
  reject(err);
2454
2526
  };
2455
- httpServer.once("error", onError);
2456
- httpServer.listen(attemptPort, ip, () => {
2527
+ function onListening() {
2457
2528
  httpServer.off("error", onError);
2458
2529
  const address = httpServer.address();
2459
2530
  const boundPort = typeof address === "object" && address !== null ? address.port : attemptPort;
@@ -2463,7 +2534,7 @@ var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((re
2463
2534
  process.once("exit", clearDevServerInfo);
2464
2535
  }
2465
2536
  if (attemptPort !== startPort && !production) {
2466
- const configuredCallbackBase = tryCatchSync3(() => getProjectConfig15().oauthCallbackBase)[1] ?? "";
2537
+ const configuredCallbackBase = tryCatchSync3(() => getProjectConfig16().oauthCallbackBase)[1] ?? "";
2467
2538
  const callbackPort = tryCatchSync3(() => {
2468
2539
  const callbackUrl = new URL(configuredCallbackBase);
2469
2540
  return callbackUrl.port || (callbackUrl.protocol === "https:" ? "443" : "80");
@@ -2474,13 +2545,16 @@ var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((re
2474
2545
  );
2475
2546
  }
2476
2547
  }
2477
- const config = getProjectConfig15();
2548
+ const config = getProjectConfig16();
2478
2549
  if (config.logging.socketStartup || config.logging.devLogs) {
2479
2550
  getLogger14().info(`Server is running on http://${ip}:${String(boundPort)}/`);
2480
2551
  }
2481
2552
  callback?.();
2482
2553
  resolve(httpServer);
2483
- });
2554
+ }
2555
+ httpServer.once("error", onError);
2556
+ httpServer.once("listening", onListening);
2557
+ httpServer.listen(attemptPort, ip);
2484
2558
  };
2485
2559
  tryListen(startPort);
2486
2560
  });
@@ -2507,7 +2581,7 @@ var createLuckyStackServer = async (options = {}) => {
2507
2581
  if (enableDevTools) {
2508
2582
  await initDevTools();
2509
2583
  }
2510
- const [bootUuidError] = await tryCatch11(() => writeBootUuid());
2584
+ const [bootUuidError] = await tryCatch12(() => writeBootUuid());
2511
2585
  if (bootUuidError) {
2512
2586
  throw new Error(
2513
2587
  "Failed to write the boot UUID to Redis. Check REDIS_HOST / REDIS_PORT / REDIS_USER / REDIS_PASSWORD and that Redis is reachable.",
@@ -2557,7 +2631,7 @@ var createLuckyStackServer = async (options = {}) => {
2557
2631
  import path3 from "path";
2558
2632
  import fs2 from "fs";
2559
2633
  import { pathToFileURL } from "url";
2560
- import { ROOT_DIR, tryCatch as tryCatch12, getLogger as getLogger15, isTestFile, isTestDirectory } from "@luckystack/core";
2634
+ import { ROOT_DIR, tryCatch as tryCatch13, getLogger as getLogger15, isTestFile, isTestDirectory } from "@luckystack/core";
2561
2635
  var OVERLAY_ORDER = [
2562
2636
  // Core registries first — clients, paths, routing rules. Anything below
2563
2637
  // depends on these being in place.
@@ -2582,7 +2656,7 @@ var OVERLAY_ORDER = [
2582
2656
  ];
2583
2657
  var importIfExists = async (filePath) => {
2584
2658
  if (!fs2.existsSync(filePath)) return;
2585
- const [error] = await tryCatch12(async () => {
2659
+ const [error] = await tryCatch13(async () => {
2586
2660
  await import(pathToFileURL(filePath).href);
2587
2661
  });
2588
2662
  if (error) {