@luckystack/server 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -281,7 +281,12 @@ declare const getPreParamsCustomRoutes: () => readonly CustomRouteHandler[];
281
281
  declare const clearCustomRoutes: () => void;
282
282
 
283
283
  interface OriginExemptMatcher {
284
- /** A route is exempt when its path starts with this prefix. */
284
+ /**
285
+ * A route is exempt when its path equals this prefix OR continues past it on a
286
+ * path-SEGMENT boundary (i.e. `<prefix>/...`). Matching is boundary-aware so a
287
+ * prefix can't bleed into a sibling route — `/webhooks` exempts `/webhooks` and
288
+ * `/webhooks/stripe` but NOT `/webhooksadmin`.
289
+ */
285
290
  pathPrefix: string;
286
291
  }
287
292
  declare const registerOriginExemptPath: (matcher: OriginExemptMatcher) => void;
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  // src/createServer.ts
9
9
  import http from "http";
10
- import { registerBindAddress, writeBootUuid, getLogger as getLogger13, getProjectConfig as getProjectConfig13, tryCatch as tryCatch10, isProduction as isProduction2, resolveEnvKey as resolveEnvKey4 } from "@luckystack/core";
10
+ import { registerBindAddress, writeBootUuid, getLogger as getLogger13, getProjectConfig as getProjectConfig13, tryCatch as tryCatch10, isProduction as isProduction2, resolveEnvKey as resolveEnvKey5 } from "@luckystack/core";
11
11
 
12
12
  // src/httpHandler.ts
13
13
  import { randomUUID } from "crypto";
@@ -138,7 +138,12 @@ var getOriginExemptPaths = () => exemptPaths;
138
138
  var clearOriginExemptPaths = () => {
139
139
  exemptPaths.length = 0;
140
140
  };
141
- var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
141
+ var isOriginExemptPath = (routePath) => exemptPaths.some(({ pathPrefix }) => {
142
+ if (!pathPrefix) return false;
143
+ if (routePath === pathPrefix) return true;
144
+ const boundary = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`;
145
+ return routePath.startsWith(boundary);
146
+ });
142
147
 
143
148
  // src/httpRoutes/timingSafeEqual.ts
144
149
  import { timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
@@ -427,7 +432,12 @@ import {
427
432
  } from "@luckystack/core";
428
433
 
429
434
  // src/httpRoutes/sessionCookie.ts
430
- var resolveCookieSecure = (sessionCookieSecure, secureEnv) => sessionCookieSecure ?? secureEnv === "true";
435
+ import { resolveEnvKey as resolveEnvKey2 } from "@luckystack/core";
436
+ var resolveCookieSecure = (sessionCookieSecure, secureEnv) => {
437
+ if (sessionCookieSecure !== void 0) return sessionCookieSecure;
438
+ if (secureEnv === "true") return true;
439
+ return resolveEnvKey2() === "production";
440
+ };
431
441
 
432
442
  // src/httpRoutes/authApiRoute.ts
433
443
  var parseSessionBasedTokenHeader = (headerValue) => {
@@ -472,6 +482,40 @@ var handleAuthApiRoute = async ({
472
482
  return true;
473
483
  }
474
484
  if (login.isFullOAuthProvider(provider)) {
485
+ const oauthRateLimiting = config.rateLimiting;
486
+ const oauthRequesterIp = resolveClientIp({
487
+ rawAddress: req.socket.remoteAddress,
488
+ headers: req.headers,
489
+ trustProxy: config.http.trustProxy,
490
+ trustedProxyHopCount: config.http.trustedProxyHopCount
491
+ });
492
+ const oauthInitLimit = oauthRateLimiting.defaultApiLimit !== false && oauthRateLimiting.defaultApiLimit > 0 ? oauthRateLimiting.defaultApiLimit : oauthRateLimiting.auth.enabled && oauthRateLimiting.auth.maxAttempts > 0 ? oauthRateLimiting.auth.maxAttempts : null;
493
+ if (oauthInitLimit !== null) {
494
+ const oauthInitWindowMs = oauthRateLimiting.defaultApiLimit !== false && oauthRateLimiting.defaultApiLimit > 0 ? oauthRateLimiting.windowMs : oauthRateLimiting.auth.windowMs;
495
+ const { allowed, resetIn } = await checkRateLimit({
496
+ key: `ip:${oauthRequesterIp}:auth:oauth-init`,
497
+ limit: oauthInitLimit,
498
+ windowMs: oauthInitWindowMs
499
+ });
500
+ if (!allowed) {
501
+ void dispatchHook2("rateLimitExceeded", {
502
+ scope: "auth",
503
+ key: `ip:${oauthRequesterIp}:auth:oauth-init`,
504
+ limit: oauthInitLimit,
505
+ windowMs: oauthInitWindowMs,
506
+ count: oauthInitLimit + 1,
507
+ route: routePath,
508
+ ip: oauthRequesterIp
509
+ });
510
+ res.writeHead(429, { "content-type": "application/json; charset=utf-8" });
511
+ res.end(JSON.stringify({
512
+ status: false,
513
+ reason: "api.rateLimitExceeded",
514
+ errorParams: [{ key: "seconds", value: resetIn }]
515
+ }));
516
+ return true;
517
+ }
518
+ }
475
519
  const reqUrl = new URL(req.url ?? "/", "http://placeholder");
476
520
  const returnUrl = reqUrl.searchParams.get("return_url") ?? void 0;
477
521
  const oauthState = await login.createOAuthState(provider.name, { usePkce: provider.usePkce, returnUrl });
@@ -517,7 +561,7 @@ var handleAuthApiRoute = async ({
517
561
  });
518
562
  const ipLimitCount = rateLimiting.defaultApiLimit !== false && rateLimiting.defaultApiLimit > 0 ? rateLimiting.defaultApiLimit : rateLimiting.auth.enabled && rateLimiting.auth.maxAttempts > 0 ? rateLimiting.auth.maxAttempts : null;
519
563
  if (ipLimitCount !== null) {
520
- const ipWindowMs = rateLimiting.defaultApiLimit === false ? rateLimiting.auth.windowMs : rateLimiting.windowMs;
564
+ const ipWindowMs = rateLimiting.defaultApiLimit !== false && rateLimiting.defaultApiLimit > 0 ? rateLimiting.windowMs : rateLimiting.auth.windowMs;
521
565
  const { allowed, resetIn } = await checkRateLimit({
522
566
  key: `ip:${requesterIp}:auth:credentials`,
523
567
  limit: ipLimitCount,
@@ -1163,7 +1207,7 @@ var enforceOriginPolicy = (req, res, routePath) => {
1163
1207
  });
1164
1208
  const isStateChangingMethod = req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS";
1165
1209
  if (isOriginExemptPath(routePath)) {
1166
- return { origin, rejected: false };
1210
+ return { origin: "", rejected: false };
1167
1211
  }
1168
1212
  if (!origin) {
1169
1213
  if (isStateChangingMethod) {
@@ -1419,8 +1463,7 @@ var executeRoomMutation = async (opts) => {
1419
1463
  return;
1420
1464
  }
1421
1465
  const existingRoomCodes = getSessionRoomCodes(session);
1422
- const roomPurpose = preHook === "preRoomJoin" ? "join" : "leave";
1423
- const physicalRoom = formatRoomName(group, { purpose: roomPurpose, userId: session.id });
1466
+ const physicalRoom = formatRoomName(group, { purpose: "broadcast", userId: session.id });
1424
1467
  const nextRoomCodes = await mutate(socket, physicalRoom, group, existingRoomCodes, session.id);
1425
1468
  const sanitizedSession = sanitizeSessionRoomKeys(session);
1426
1469
  await writeSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
@@ -1486,7 +1529,7 @@ var registerRoomEvents = (ctx) => {
1486
1529
  const oldest = kept[0];
1487
1530
  if (oldest === void 0) break;
1488
1531
  kept = kept.slice(1);
1489
- await sock.leave(formatRoomName(oldest, { purpose: "join", userId }));
1532
+ await sock.leave(formatRoomName(oldest, { purpose: "broadcast", userId }));
1490
1533
  }
1491
1534
  }
1492
1535
  await sock.join(physicalRoom);
@@ -1637,7 +1680,7 @@ var rejoinPersistedRooms = (ctx) => {
1637
1680
  const roomCodes = session ? getSessionRoomCodes(session) : [];
1638
1681
  const userId = session?.id ?? null;
1639
1682
  for (const roomCode of roomCodes) {
1640
- await socket.join(formatRoomName(roomCode, { purpose: "join", userId }));
1683
+ await socket.join(formatRoomName(roomCode, { purpose: "broadcast", userId }));
1641
1684
  }
1642
1685
  return roomCodes;
1643
1686
  });
@@ -1727,7 +1770,7 @@ import {
1727
1770
  isLocalizedNormalizerRegistered,
1728
1771
  isProjectConfigRegistered,
1729
1772
  isRuntimeMapsProviderRegistered,
1730
- resolveEnvKey as resolveEnvKey2
1773
+ resolveEnvKey as resolveEnvKey3
1731
1774
  } from "@luckystack/core";
1732
1775
  var verifyBootstrap = async (requirements = {}) => {
1733
1776
  const missing = [];
@@ -1766,7 +1809,7 @@ var verifyBootstrap = async (requirements = {}) => {
1766
1809
  }
1767
1810
  }
1768
1811
  if (!isRuntimeMapsProviderRegistered()) {
1769
- if (resolveEnvKey2() === "production") {
1812
+ if (resolveEnvKey3() === "production") {
1770
1813
  missing.push(
1771
1814
  "RuntimeMapsProvider \u2014 call `registerRuntimeMapsProvider({...})` from `server/prod/runtimeMaps.ts`. Without it, every api/sync request returns notFound."
1772
1815
  );
@@ -1777,7 +1820,7 @@ var verifyBootstrap = async (requirements = {}) => {
1777
1820
  }
1778
1821
  }
1779
1822
  if (!isLocalizedNormalizerRegistered()) {
1780
- if (resolveEnvKey2() === "production") {
1823
+ if (resolveEnvKey3() === "production") {
1781
1824
  missing.push(
1782
1825
  "LocalizedNormalizer \u2014 call `registerLocalizedNormalizer({...})` from your bootstrap. Without it, error response messages will be the raw errorCode (no i18n)."
1783
1826
  );
@@ -1796,6 +1839,14 @@ var verifyBootstrap = async (requirements = {}) => {
1796
1839
  );
1797
1840
  }
1798
1841
  }
1842
+ if (isProjectConfigRegistered()) {
1843
+ const cfg = getProjectConfig12();
1844
+ if (!cfg.session.basedToken && cfg.http.sessionCookieSameSite !== "Strict") {
1845
+ getLogger9().warn(
1846
+ `[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.`
1847
+ );
1848
+ }
1849
+ }
1799
1850
  if (missing.length === 0) return;
1800
1851
  const detail = missing.map((line, idx) => ` ${idx + 1}. ${line}`).join("\n");
1801
1852
  throw new Error(
@@ -1812,7 +1863,7 @@ var verifyBootstrap = async (requirements = {}) => {
1812
1863
  import {
1813
1864
  getLogger as getLogger10,
1814
1865
  registerRuntimeMapsProvider,
1815
- resolveEnvKey as resolveEnvKey3
1866
+ resolveEnvKey as resolveEnvKey4
1816
1867
  } from "@luckystack/core";
1817
1868
  var emptyRuntimeMaps = {
1818
1869
  apisObject: {},
@@ -1862,7 +1913,7 @@ var mergeInto = (target, source, kind, fromPreset, keyOrigin) => {
1862
1913
  target[key] = source[key];
1863
1914
  }
1864
1915
  };
1865
- var isProduction = () => resolveEnvKey3() === "production";
1916
+ var isProduction = () => resolveEnvKey4() === "production";
1866
1917
  var createProdRuntimeMapsProvider = (options) => {
1867
1918
  let prodMapsPromise = null;
1868
1919
  let devkitModulePromise = null;
@@ -2104,7 +2155,7 @@ var createLuckyStackServer = async (options = {}) => {
2104
2155
  });
2105
2156
  const port = options.port ?? getParsedPort() ?? options.defaultPort ?? process.env.SERVER_PORT ?? 80;
2106
2157
  const ip = options.ip ?? process.env.SERVER_IP ?? "127.0.0.1";
2107
- const enableDevTools = options.enableDevTools ?? resolveEnvKey4() !== "production";
2158
+ const enableDevTools = options.enableDevTools ?? resolveEnvKey5() !== "production";
2108
2159
  registerBindAddress({
2109
2160
  ip,
2110
2161
  port: typeof port === "string" ? Number.parseInt(port, 10) : port