@luckystack/core 0.6.7 → 0.7.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +148 -107
  2. package/CLAUDE.md +245 -245
  3. package/LICENSE +21 -21
  4. package/README.md +93 -93
  5. package/dist/{chunk-WFABOO6J.js → chunk-AFG7KKMS.js} +3 -3
  6. package/dist/chunk-AFG7KKMS.js.map +1 -0
  7. package/dist/{chunk-3AWP2YEA.js → chunk-J3UOWYT4.js} +2 -28
  8. package/dist/chunk-J3UOWYT4.js.map +1 -0
  9. package/dist/{chunk-LU3IPFTR.js → chunk-LTCDQU5Z.js} +1 -1
  10. package/dist/chunk-LTCDQU5Z.js.map +1 -0
  11. package/dist/chunk-R7IRJFVC.js +67 -0
  12. package/dist/chunk-R7IRJFVC.js.map +1 -0
  13. package/dist/client.d.ts +3 -2
  14. package/dist/client.js +4 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/config-DZCzBNwP.d.ts +122 -0
  17. package/dist/config.d.ts +2 -0
  18. package/dist/config.js +31 -0
  19. package/dist/eslint/index.js.map +1 -1
  20. package/dist/index.d.ts +21 -149
  21. package/dist/index.js +66 -70
  22. package/dist/index.js.map +1 -1
  23. package/dist/pageRouteValidation-sJJCX-YV.d.ts +270 -0
  24. package/dist/{projectConfig-XL5VB76A.js → projectConfig-DBLSQ3NY.js} +2 -2
  25. package/dist/{sentrySetup-4NTWLN2A.js → sentrySetup-MF6JSVWA.js} +3 -3
  26. package/dist/sentrySetup-MF6JSVWA.js.map +1 -0
  27. package/dist/{pageRouteValidation-gqktrJci.d.ts → sessionTypes-Dzen_rPK.d.ts} +23 -265
  28. package/docs/app-bootstrap.md +418 -418
  29. package/docs/config-registry.md +348 -348
  30. package/docs/csrf-config.md +88 -88
  31. package/docs/error-tracker-registry.md +194 -174
  32. package/docs/hooks.md +298 -298
  33. package/docs/rate-limit-strategy.md +170 -170
  34. package/docs/redis-adapter.md +111 -111
  35. package/docs/session-types.md +180 -180
  36. package/docs/socket-bootstrap.md +366 -366
  37. package/package.json +6 -2
  38. package/dist/chunk-3AWP2YEA.js.map +0 -1
  39. package/dist/chunk-LU3IPFTR.js.map +0 -1
  40. package/dist/chunk-WFABOO6J.js.map +0 -1
  41. /package/dist/{projectConfig-XL5VB76A.js.map → config.js.map} +0 -0
  42. /package/dist/{sentrySetup-4NTWLN2A.js.map → projectConfig-DBLSQ3NY.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/core",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/client.d.ts",
46
46
  "import": "./dist/client.js"
47
47
  },
48
+ "./config": {
49
+ "types": "./dist/config.d.ts",
50
+ "import": "./dist/config.js"
51
+ },
48
52
  "./eslint": {
49
53
  "types": "./dist/eslint/index.d.ts",
50
54
  "import": "./dist/eslint/index.js"
@@ -62,7 +66,7 @@
62
66
  "CHANGELOG.md"
63
67
  ],
64
68
  "scripts": {
65
- "build": "tsup",
69
+ "build": "tsup && node scripts/checkClientBundle.mjs",
66
70
  "build:watch": "tsup --watch",
67
71
  "test": "vitest run"
68
72
  },
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/errorTrackerRegistry.ts","../src/redactedLogKeys.ts","../src/loggerRegistry.ts","../src/sentrySetup.ts"],"sourcesContent":["//? Backend-agnostic error-tracker registry. Lives in @luckystack/core\r\n//? (not @luckystack/error-tracking) because framework code in core /\r\n//? api / sync needs to call it without taking a hard dep on a specific\r\n//? observability backend. The error-tracking package provides adapter\r\n//? IMPLEMENTATIONS (Sentry, Datadog, PostHog); the slot lives here.\r\n//?\r\n//? Multiple adapters can be registered at once via `registerErrorTrackers([...])` —\r\n//? every event fans out to all of them. Per-adapter errors are swallowed\r\n//? so one buggy tracker can't break the chain.\r\n\r\n//? SERVER-ONLY import. This static import of `node:async_hooks` is intentional\r\n//? and MUST remain externalized in any client-bundle build config (Vite/esbuild\r\n//? `external: ['node:async_hooks']`). Although AsyncLocalStorage is never\r\n//? constructed at module-eval time (the lazy `getIdentityStore()` guard handles\r\n//? that), the import itself will cause a runtime error in the browser if the\r\n//? bundler does not externalize it. Do NOT statically import this file from\r\n//? browser-bundle entry points — use `@luckystack/core/client` instead.\r\nimport * as nodeAsyncHooks from 'node:async_hooks';\r\nimport { getRedactedLogKeys, REDACTED_PLACEHOLDER, sanitizeForLog } from './redactedLogKeys';\r\nimport { getLogger } from './loggerRegistry';\r\n\r\nexport type ErrorTrackerContext = Record<string, unknown>;\r\n\r\n//? SYNC-17 defense-in-depth: scrub registered redacted keys (tokens, passwords,\r\n//? auth/cookie headers) from the context object before it fans out to any\r\n//? adapter, so a raw token nested in a capture context never reaches Sentry /\r\n//? Datadog / PostHog breadcrumbs. Returns the original reference when there is\r\n//? nothing to sanitize (no context) to avoid needless allocation on the hot path.\r\nconst sanitizeContext = (context?: ErrorTrackerContext): ErrorTrackerContext | undefined => {\r\n if (!context) return context;\r\n return sanitizeForLog(context) as ErrorTrackerContext;\r\n};\r\n\r\n//? ET-O2 value-level scrub: error.message and error.stack can contain\r\n//? interpolated secrets (\"token=abc123\") that bypass the key-based context\r\n//? scrub. Replace `key=value` and `key: value` patterns for every registered\r\n//? redacted key so the string never reaches an adapter verbatim.\r\n//? Runs at capture time (not on every Error construction) to stay off the\r\n//? hot path; the result is cached on the sanitized context so each adapter\r\n//? fan-out step reads the same pre-scrubbed string.\r\n//? BOOT-TIME ONLY: `registerRedactedLogKeys` must be called at boot with a\r\n//? static key set (framework package init / project boot). Calling it with\r\n//? dynamically generated strings (route names, tenant IDs, etc.) at request\r\n//? time will cause this cache to grow without bound. A hard cap (200 entries)\r\n//? logs a warning when exceeded so misuse is immediately diagnosable.\r\nconst REDACTED_VALUE_RE_CACHE_MAX = 200;\r\ninterface ScrubPatternPair {\r\n urlParam: RegExp; // matches <key>=<value> (URL-param style)\r\n logLabel: RegExp; // matches <key>: <value> (log-label style)\r\n}\r\nconst REDACTED_VALUE_RE_CACHE = new Map<string, ScrubPatternPair>();\r\n\r\nconst buildScrubPattern = (key: string): ScrubPatternPair => {\r\n const cached = REDACTED_VALUE_RE_CACHE.get(key);\r\n if (cached) return cached;\r\n if (REDACTED_VALUE_RE_CACHE.size >= REDACTED_VALUE_RE_CACHE_MAX) {\r\n try {\r\n getLogger().warn(`errorTracker: REDACTED_VALUE_RE_CACHE exceeded ${String(REDACTED_VALUE_RE_CACHE_MAX)} entries — registerRedactedLogKeys must only be called at boot with a static key set, not with dynamic/runtime-varying strings.`);\r\n } catch {\r\n // last-resort swallow\r\n }\r\n }\r\n const escaped = key.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, String.raw`\\$&`);\r\n //? Two separate passes — one for `key=value` (URL-param style), one for\r\n //? `key: value` (log-label style) — to avoid alternation complexity and the\r\n //? subtle behaviours that arise when mixing capturing and non-capturing groups\r\n //? inside a single pattern (ET-O2 alternation fix).\r\n //? `+` (not `*`) for the value portion so `key=` with an empty value is NOT\r\n //? replaced (empty parameters are not secrets worth scrubbing and a `*` match\r\n //? would turn innocent `foo=` markers into `[redacted]`).\r\n const pair: ScrubPatternPair = {\r\n urlParam: new RegExp(String.raw`${escaped}=[^\\s,;\"'&]+`, 'gi'),\r\n logLabel: new RegExp(String.raw`${escaped}:\\s*[^\\s,;\"']+`, 'gi'),\r\n };\r\n REDACTED_VALUE_RE_CACHE.set(key, pair);\r\n return pair;\r\n};\r\n\r\nexport const sanitizeErrorString = (value: string): string => {\r\n let result = value;\r\n // Scrub matching `key=value` / `key: value` patterns for every registered\r\n // redacted key so interpolated secrets (e.g. \"token=abc\") never reach an adapter.\r\n for (const key of getRedactedLogKeys()) {\r\n const { urlParam, logLabel } = buildScrubPattern(key);\r\n result = result.replace(urlParam, REDACTED_PLACEHOLDER).replace(logLabel, REDACTED_PLACEHOLDER);\r\n }\r\n return result;\r\n};\r\n\r\n//? Produce a sanitized version of an Error's message and stack so adapters\r\n//? can emit pre-scrubbed strings without touching the original Error object.\r\nexport const sanitizeErrorStrings = (\r\n error: unknown,\r\n): { message: string; stack: string | undefined } | null => {\r\n if (!(error instanceof Error)) return null;\r\n return {\r\n message: sanitizeErrorString(error.message),\r\n stack: error.stack === undefined ? undefined : sanitizeErrorString(error.stack),\r\n };\r\n};\r\n\r\nexport interface ErrorTrackerUser {\r\n id?: string;\r\n email?: string;\r\n username?: string;\r\n [key: string]: unknown;\r\n}\r\n\r\nexport interface ErrorTrackerEvent {\r\n /** When false, the adapter must not forward this event (beforeSend opt-out). */\r\n forwarded: boolean;\r\n kind: 'exception' | 'message';\r\n payload: Record<string, unknown>;\r\n}\r\n\r\n//? `SpanResult<T>` was a conditional unwrap of Promise. It's now an alias\r\n//? for `T` directly because the conditional collapsed to T in both branches\r\n//? and tsup's dts emit choked on the conditional widening. Kept as an\r\n//? exported alias so adapter authors can still annotate with `SpanResult<T>`\r\n//? for documentation intent.\r\nexport type SpanResult<T> = T;\r\n\r\nexport interface ErrorTracker {\r\n /** Human-readable identifier (logs, diagnostics). */\r\n name: string;\r\n captureException: (error: unknown, context?: ErrorTrackerContext) => void;\r\n captureMessage: (\r\n message: string,\r\n level: 'info' | 'warning' | 'error' | 'fatal',\r\n context?: ErrorTrackerContext,\r\n ) => void;\r\n setUser: (user: ErrorTrackerUser | null) => void;\r\n setContext?: (key: string, context: ErrorTrackerContext | null) => void;\r\n startSpan?: <T>(name: string, op: string, fn: () => T) => T;\r\n /**\r\n * Optional handle-style span (#62): open a span NOW and close it later via the\r\n * returned handle's `finish()`. Unlike {@link startSpan} (which wraps a single\r\n * synchronous `fn`), this fits an open-at-preExecute / close-at-postExecute\r\n * request lifecycle that spans awaits. When present, the registry-level\r\n * {@link startSpanHandle} delegates to the FIRST registered adapter that\r\n * implements it so the request span reaches an adapter-only backend\r\n * (Datadog/PostHog/custom), not just the legacy Sentry SDK path.\r\n */\r\n startSpanHandle?: (name: string, op: string) => { finish: () => void };\r\n recordMetric?: (name: string, value: number, tags?: Record<string, string>) => void;\r\n beforeSend?: (event: ErrorTrackerEvent) => ErrorTrackerEvent | null;\r\n /**\r\n * Optional flush lifecycle hook (error-tracking ET batch). Called by\r\n * {@link flushErrorTrackers} on graceful shutdown so a buffered adapter\r\n * (PostHog batch, Sentry transport) can drain in-flight events before exit.\r\n * Returns a promise that resolves when the adapter has flushed.\r\n */\r\n flush?: () => Promise<void>;\r\n}\r\n\r\n//? ET-02 fix seam: per-event identity carried in AsyncLocalStorage instead of a\r\n//? process-global mutable `currentDistinctId`. The framework's API + sync request\r\n//? handlers open an identity SCOPE at request entry (`runWithErrorTrackerIdentityScope`)\r\n//? — BEFORE the first await that could interleave with another concurrent request —\r\n//? then write the resolved session into it via `setCurrentErrorTrackerIdentity(user)`\r\n//? once `readSession` resolves. Adapters read `getCurrentErrorTrackerIdentity()` at\r\n//? capture time, so two concurrent requests with different users can't cross-attribute\r\n//? events. Lives in core so the registry (and any adapter) can read it without a\r\n//? back-dependency on error-tracking.\r\n//?\r\n//? The store holds a MUTABLE box (not the user directly) so the identity can be set\r\n//? AFTER the scope is entered: AsyncLocalStorage propagates the same box reference to\r\n//? every async child of the scope, and each request gets its own box — so mutating\r\n//? `box.user` post-`await` is visible to that request's captures only, never another's.\r\ninterface IdentityBox {\r\n user: ErrorTrackerUser | null;\r\n}\r\n\r\n//? BROWSER-SAFE LAZY ALS: this registry is reachable from the client bundle, but\r\n//? `node:async_hooks` only exists on the server — vite externalizes it and THROWS\r\n//? on any property access in the browser. So we never construct/touch\r\n//? AsyncLocalStorage at module-eval; we resolve it lazily behind a server guard\r\n//? (`typeof window === 'undefined'`). In the browser the store is null and every\r\n//? identity helper degrades to a no-op / null (the client never needs per-request\r\n//? identity). On the server, behaviour is identical to a top-level store (ET-02).\r\ntype IdentityStore = nodeAsyncHooks.AsyncLocalStorage<IdentityBox>;\r\nlet resolvedIdentityStore: IdentityStore | null | undefined;\r\nconst getIdentityStore = (): IdentityStore | null => {\r\n if (resolvedIdentityStore !== undefined) return resolvedIdentityStore;\r\n //? Server detection via property presence — `'window' in globalThis` is false\r\n //? in Node and avoids the DOM-lib typing that makes `globalThis.window === undefined`\r\n //? a \"no-overlap\" lint error (and avoids a ReferenceError on a bare `window`).\r\n resolvedIdentityStore =\r\n 'window' in globalThis ? null : new nodeAsyncHooks.AsyncLocalStorage<IdentityBox>();\r\n return resolvedIdentityStore;\r\n};\r\n\r\n/**\r\n * Open a per-request error-tracker identity scope and run `fn` inside it. Call at\r\n * request ENTRY (before any await that could interleave with another request); the\r\n * identity starts null and is filled in later via {@link setCurrentErrorTrackerIdentity}\r\n * once the session is known. Each invocation gets an isolated box (ET-02).\r\n */\r\nexport const runWithErrorTrackerIdentityScope = <T>(fn: () => T): T => {\r\n const store = getIdentityStore();\r\n return store ? store.run({ user: null }, fn) : fn();\r\n};\r\n\r\n/**\r\n * Run `fn` with `user` bound as the ambient error-tracker identity (ET-02). Convenience\r\n * wrapper that opens a scope and immediately sets the identity — used where the user is\r\n * already known up front (and by the adapter regression tests).\r\n */\r\nexport const runWithErrorTrackerIdentity = <T>(user: ErrorTrackerUser | null, fn: () => T): T => {\r\n const store = getIdentityStore();\r\n return store ? store.run({ user }, fn) : fn();\r\n};\r\n\r\n/**\r\n * Write `user` into the active identity box opened by {@link runWithErrorTrackerIdentityScope}\r\n * / {@link runWithErrorTrackerIdentity}. No-op when called outside any scope (a background /\r\n * non-request capture has no per-request box; adapters fall back to their own global).\r\n */\r\nexport const setCurrentErrorTrackerIdentity = (user: ErrorTrackerUser | null): void => {\r\n const box = getIdentityStore()?.getStore();\r\n if (box) box.user = user;\r\n};\r\n\r\n/** Read the ambient per-event identity for the active request scope, if any. */\r\nexport const getCurrentErrorTrackerIdentity = (): ErrorTrackerUser | null =>\r\n getIdentityStore()?.getStore()?.user ?? null;\r\n\r\n//? Pre-capture filter (error-tracking ET batch). A registered filter runs on\r\n//? EVERY event just before fan-out; returning `false` DROPS the event entirely\r\n//? (e.g. suppress known-noisy errors, sample, or honour a per-event opt-out).\r\n//? Distinct from a per-adapter `beforeSend` (which transforms a single adapter's\r\n//? payload) — this gates the whole fan-out. Last-write-wins; `null` clears it.\r\nexport type PreCaptureFilter = (event: ErrorTrackerEvent) => boolean;\r\nlet preCaptureFilter: PreCaptureFilter | null = null;\r\n\r\nexport const registerPreCaptureFilter = (filter: PreCaptureFilter | null): void => {\r\n preCaptureFilter = filter;\r\n};\r\n\r\nconst passesPreCaptureFilter = (kind: 'exception' | 'message', payload: Record<string, unknown>): boolean => {\r\n if (!preCaptureFilter) return true;\r\n try {\r\n return preCaptureFilter({ forwarded: true, kind, payload });\r\n } catch {\r\n //? A throwing filter must not swallow telemetry — fail OPEN (forward).\r\n return true;\r\n }\r\n};\r\n\r\n//? ET-O6: the pre-capture filter payload now includes the error / message\r\n//? so filters can drop noisy errors by type/message (the documented use case).\r\n//? Separate helpers per kind so the call site stays type-safe.\r\nconst exceptionFilterPayload = (\r\n error: unknown,\r\n context: ErrorTrackerContext | undefined,\r\n): Record<string, unknown> => ({ error, context: context ?? null });\r\n\r\nconst messageFilterPayload = (\r\n message: string,\r\n level: string,\r\n context: ErrorTrackerContext | undefined,\r\n): Record<string, unknown> => ({ message, level, context: context ?? null });\r\n\r\nlet activeTrackers: ErrorTracker[] = [];\r\n\r\n//? REPLACE semantics — last-write-wins (ET-24 confirmed standard). Use\r\n//? `appendErrorTracker` when you need accumulate-not-replace (ET-05).\r\nexport const registerErrorTracker = (tracker: ErrorTracker): void => {\r\n activeTrackers = [tracker];\r\n};\r\n\r\nexport const registerErrorTrackers = (trackers: ErrorTracker[]): void => {\r\n activeTrackers = [...trackers];\r\n};\r\n\r\n/**\r\n * APPEND primitive (ET-05): add a tracker WITHOUT clobbering already-registered\r\n * ones. Fixes the async-PostHog-vs-consumer-overlay race where the loser of\r\n * `registerErrorTracker`'s replace silently vanished. De-duplicates by `name`\r\n * (re-appending the same adapter name replaces that one entry in place) so a\r\n * lazy adapter that registers a proxy then the real client doesn't double-fire.\r\n */\r\nexport const appendErrorTracker = (tracker: ErrorTracker): void => {\r\n const withoutSameName = activeTrackers.filter((t) => t.name !== tracker.name);\r\n activeTrackers = [...withoutSameName, tracker];\r\n};\r\n\r\nexport const getActiveErrorTrackers = (): ErrorTracker[] => activeTrackers;\r\n\r\n/**\r\n * Flush lifecycle (error-tracking ET batch): drain every adapter that exposes a\r\n * `flush()` before shutdown. Per-adapter failures are swallowed + logged so one\r\n * stuck adapter can't block graceful shutdown. Safe to call when no adapter has\r\n * `flush`.\r\n */\r\nexport const flushErrorTrackers = async (): Promise<void> => {\r\n await Promise.all(\r\n activeTrackers.map(async (tracker) => {\r\n if (!tracker.flush) return;\r\n try {\r\n await tracker.flush();\r\n } catch (error) {\r\n //? ET-O7: route to the shared failure logger (but never re-throw — a\r\n //? failing flush must not block graceful shutdown).\r\n logTrackerFailure(tracker.name, 'flush', error);\r\n }\r\n }),\r\n );\r\n};\r\n\r\nexport const captureExceptionAcrossTrackers = (\r\n error: unknown,\r\n context?: ErrorTrackerContext,\r\n): void => {\r\n const safeContext = sanitizeContext(context);\r\n if (!passesPreCaptureFilter('exception', exceptionFilterPayload(error, safeContext))) return;\r\n for (const tracker of activeTrackers) {\r\n try {\r\n tracker.captureException(error, safeContext);\r\n } catch (trackerError) {\r\n //? Fan-out error logging (error-tracking ET batch): a buggy tracker must\r\n //? not break the chain, but its failure was previously invisible. Log it\r\n //? (best-effort) so a misbehaving adapter is diagnosable.\r\n logTrackerFailure(tracker.name, 'captureException', trackerError);\r\n }\r\n }\r\n};\r\n\r\nexport const captureMessageAcrossTrackers = (\r\n message: string,\r\n level: 'info' | 'warning' | 'error' | 'fatal',\r\n context?: ErrorTrackerContext,\r\n): void => {\r\n const safeContext = sanitizeContext(context);\r\n if (!passesPreCaptureFilter('message', messageFilterPayload(message, level, safeContext))) return;\r\n for (const tracker of activeTrackers) {\r\n try {\r\n tracker.captureMessage(message, level, safeContext);\r\n } catch (trackerError) {\r\n logTrackerFailure(tracker.name, 'captureMessage', trackerError);\r\n }\r\n }\r\n};\r\n\r\n//? Best-effort fan-out failure logger. Wrapped in its own try/catch because the\r\n//? logger itself could throw on a broken sink, and a capture path must never\r\n//? throw.\r\nconst logTrackerFailure = (trackerName: string, op: string, error: unknown): void => {\r\n try {\r\n getLogger().warn(`errorTracker: \"${trackerName}\" threw in ${op}`, { error });\r\n } catch {\r\n // Last-resort swallow.\r\n }\r\n};\r\n\r\nexport const setErrorTrackerUser = (user: ErrorTrackerUser | null): void => {\r\n for (const tracker of activeTrackers) {\r\n try {\r\n tracker.setUser(user);\r\n } catch (error) {\r\n //? ET-O7: route to the shared failure logger so a misbehaving tracker is diagnosable.\r\n logTrackerFailure(tracker.name, 'setUser', error);\r\n }\r\n }\r\n};\r\n\r\nexport const recordMetricAcrossTrackers = (\r\n name: string,\r\n value: number,\r\n tags?: Record<string, string>,\r\n): void => {\r\n for (const tracker of activeTrackers) {\r\n if (tracker.recordMetric) {\r\n try {\r\n tracker.recordMetric(name, value, tags);\r\n } catch (error) {\r\n //? ET-O7: route to the shared failure logger so a misbehaving tracker is diagnosable.\r\n logTrackerFailure(tracker.name, 'recordMetric', error);\r\n }\r\n }\r\n }\r\n};\r\n\r\nexport const startSpanAcrossTrackers = <T>(name: string, op: string, fn: () => T): T => {\r\n //? Spans don't fan out cleanly to multiple backends — they're nested\r\n //? execution scopes. We only invoke the FIRST registered tracker's\r\n //? startSpan (others get notified via captureException paths if they\r\n //? want to instrument). When no tracker supports spans, run the fn directly.\r\n const first = activeTrackers.find((t) => t.startSpan);\r\n if (!first?.startSpan) return fn();\r\n return first.startSpan(name, op, fn);\r\n};\r\n\r\n/**\r\n * Handle-style span (error-tracking ET batch): for callers that can't wrap the\r\n * measured work in a single synchronous `fn` (streaming, cross-await spans).\r\n * Returns a handle whose `finish()` resolves the span's duration. The default\r\n * implementation is a lightweight wall-clock timer (works with any backend);\r\n * an adapter can supply a richer span via the existing `startSpan` callback\r\n * form when it needs native instrumentation. `finish()` is idempotent.\r\n */\r\nexport interface SpanHandle {\r\n /** Stop the span; safe to call more than once (subsequent calls are no-ops). */\r\n finish: () => void;\r\n /** Milliseconds elapsed at the moment `finish()` first ran (0 before). */\r\n readonly durationMs: number;\r\n}\r\n\r\n//? #62: open a handle-style span on the FIRST registered adapter that implements\r\n//? `startSpanHandle`, swallowing a throwing adapter so the wall-clock handle still\r\n//? works (telemetry must never break the request path). Returns `null` when no\r\n//? adapter supports handle-style spans.\r\nconst openDelegateSpanHandle = (name: string, op: string): { finish: () => void } | null => {\r\n const tracker = activeTrackers.find((t) => t.startSpanHandle);\r\n if (!tracker?.startSpanHandle) return null;\r\n try {\r\n return tracker.startSpanHandle(name, op);\r\n } catch (error) {\r\n logTrackerFailure(tracker.name, 'startSpanHandle', error);\r\n return null;\r\n }\r\n};\r\n\r\n//? #62: delegate the handle to the first adapter that supports `startSpanHandle`\r\n//? (so an adapter-only consumer — Datadog/PostHog/custom, no legacy Sentry SDK —\r\n//? actually gets request-timing spans), and ALWAYS keep the wall-clock timer as a\r\n//? backend-agnostic fallback + `durationMs` source. `finish()` is idempotent and\r\n//? closes the delegated span exactly once.\r\n//? ET-O9: removed the per-call `recordMetricAcrossTrackers` that fired on every\r\n//? startSpanHandle invocation — `durationMs` is available for callers that want to\r\n//? emit their own metric.\r\nexport const startSpanHandle = (name: string, op: string): SpanHandle => {\r\n const startedAt = Date.now();\r\n let finishedAt: number | null = null;\r\n const delegate = openDelegateSpanHandle(name, op);\r\n return {\r\n finish: () => {\r\n if (finishedAt !== null) return;\r\n finishedAt = Date.now();\r\n if (delegate) {\r\n try {\r\n delegate.finish();\r\n } catch (error) {\r\n logTrackerFailure('startSpanHandle', 'finish', error);\r\n }\r\n }\r\n },\r\n get durationMs() {\r\n return finishedAt === null ? 0 : finishedAt - startedAt;\r\n },\r\n };\r\n};\r\n","//? Registry of log keys that get masked when the framework prints request /\r\n//? response payloads. Feature packages register their domain-specific\r\n//? sensitive keys at boot (login → `password`, email → `apiKey`, etc.) so a\r\n//? consumer of the package never has to remember to extend a static list in\r\n//? core to keep their own data out of logs.\r\n//?\r\n//? Lookups are case-insensitive. Keys are merged with the framework defaults;\r\n//? `registerRedactedLogKeys` is additive — duplicate calls are idempotent.\r\n\r\nexport const DEFAULT_REDACTED_LOG_KEYS: readonly string[] = [\r\n 'password',\r\n 'confirmpassword',\r\n 'token',\r\n 'newtoken',\r\n 'authorization',\r\n 'cookie',\r\n 'set-cookie',\r\n 'csrftoken',\r\n 'apikey',\r\n 'secret',\r\n //? 2FA (ADR 0024). `totpSecret` already matches the `secret` suffix, but\r\n //? `recoveryCodes` matches neither the exact set nor any suffix — list it\r\n //? explicitly so the log-redaction floor mirrors the session sanitizer,\r\n //? which strips both `totpSecret` and `recoveryCodes`.\r\n 'recoverycodes',\r\n];\r\n\r\nconst redactedKeys = new Set<string>(DEFAULT_REDACTED_LOG_KEYS.map((key) => key.toLowerCase()));\r\n\r\n//? Sensitive SUFFIXES (0.2.0 widening). Exact-match alone missed compound keys\r\n//? a project naturally produces — `targetToken`, `sessionToken`, `clientSecret`,\r\n//? `stripeApiKey` — leaving real secrets in logs/breadcrumbs. A key whose\r\n//? lowercased form ENDS WITH one of these is redacted in addition to the exact\r\n//? set above. Suffix (not arbitrary substring) is deliberate: it catches the\r\n//? `<qualifier><Sensitive>` naming convention while a plain `substring` test\r\n//? would over-redact benign keys (e.g. a `tokenCount` metric, a `secretSanta`\r\n//? field). Kept conservative on purpose — register extra keys explicitly via\r\n//? `registerRedactedLogKeys` for project-specific names that don't fit.\r\nconst SENSITIVE_KEY_SUFFIXES: readonly string[] = ['token', 'secret', 'apikey', 'password'];\r\n\r\nexport const registerRedactedLogKeys = (keys: readonly string[]): void => {\r\n for (const key of keys) {\r\n redactedKeys.add(key.toLowerCase());\r\n }\r\n};\r\n\r\nexport const getRedactedLogKeys = (): readonly string[] => [...redactedKeys];\r\n\r\nexport const isRedactedLogKey = (key: string): boolean => {\r\n const lower = key.toLowerCase();\r\n if (redactedKeys.has(lower)) return true;\r\n return SENSITIVE_KEY_SUFFIXES.some((suffix) => lower.endsWith(suffix));\r\n};\r\n\r\n//? Test-only helper — restore the default seed set between integration tests.\r\n//? Never call from production code; framework packages register their own\r\n//? keys at boot and clearing would re-expose them.\r\nexport const resetRedactedLogKeysForTests = (): void => {\r\n redactedKeys.clear();\r\n for (const key of DEFAULT_REDACTED_LOG_KEYS) {\r\n redactedKeys.add(key.toLowerCase());\r\n }\r\n};\r\n\r\n/** Placeholder substituted for a redacted value. */\r\nexport const REDACTED_PLACEHOLDER = '[redacted]';\r\n\r\n//? Distinct from REDACTED_PLACEHOLDER so an over-deep BENIGN value (a numeric id,\r\n//? a timestamp) isn't mislabeled as a masked secret — the marker reads as\r\n//? structural truncation, not redaction.\r\nexport const DEPTH_TRUNCATED_PLACEHOLDER = '[truncated: max depth]';\r\n\r\n//? Depth cap so a self-referential / pathologically deep object can't make the\r\n//? sanitize pass blow the stack on a hot log/capture path.\r\nconst MAX_SANITIZE_DEPTH = 8;\r\n\r\n/**\r\n * Recursively deep-copy `value`, replacing any property whose key matches the\r\n * registered redacted-keys set (case-insensitive) with {@link REDACTED_PLACEHOLDER}\r\n * (SYNC-17 defense-in-depth). Use it at raw log / `captureException` context\r\n * sites so a token/password nested anywhere in a payload never lands verbatim in\r\n * a logger sink or error-tracker breadcrumb.\r\n *\r\n * - Primitives pass through unchanged.\r\n * - Arrays are mapped element-wise (array indices are never \"keys\", so an\r\n * element is only redacted by its own nested object keys).\r\n * - Cycles / over-deep graphs are cut at {@link MAX_SANITIZE_DEPTH} with\r\n * {@link DEPTH_TRUNCATED_PLACEHOLDER}; the input is never mutated.\r\n */\r\nexport const sanitizeForLog = (value: unknown, depth = 0, _seen?: WeakSet<object>): unknown => {\r\n if (depth > MAX_SANITIZE_DEPTH) return DEPTH_TRUNCATED_PLACEHOLDER;\r\n if (value === null || typeof value !== 'object') return value;\r\n\r\n //? Cycle detection: if we have already visited this reference higher up in the\r\n //? call stack, replace it with a marker rather than recursing into it — this\r\n //? prevents a stack overflow when `value` contains a circular reference (e.g.\r\n //? an Error whose `.cause` links back to itself, or a Node IncomingMessage\r\n //? with circular HTTP-socket references).\r\n const seen = _seen ?? new WeakSet<object>();\r\n if (seen.has(value)) return '[circular]';\r\n seen.add(value);\r\n\r\n if (Array.isArray(value)) {\r\n return value.map((item) => sanitizeForLog(item, depth + 1, seen));\r\n }\r\n\r\n const out: Record<string, unknown> = {};\r\n for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {\r\n out[key] = isRedactedLogKey(key) ? REDACTED_PLACEHOLDER : sanitizeForLog(nested, depth + 1, seen);\r\n }\r\n return out;\r\n};\r\n","//? Pluggable logger so framework code does not have to write directly to\r\n//? stdout. The default implementation uses `console.{debug,info,warn,error}`\r\n//? directly with no trailing color-code argument — that argument was only\r\n//? meaningful when `initConsolelog()` (a dev-only monkey-patch) had been\r\n//? installed, but the default logger needs to be safe in production too.\r\n//?\r\n//? Installers route framework logs to Pino / Winston / Datadog / etc. by\r\n//? calling `registerLogger({ debug, info, warn, error })` once at boot,\r\n//? mirroring the rest of the registry pattern in this package. Dev installs\r\n//? that want colored output should call `registerLogger(createDevLogger())`.\r\n//?\r\n//? Read at call-time via `getLogger()` so registration order does not matter\r\n//? — same contract as `getProjectConfig()` and friends.\r\n\r\nimport { createRegistry } from './createRegistry';\r\nimport { getProjectConfig } from './projectConfig';\r\n\r\nexport type LoggerContext = Record<string, unknown>;\r\n\r\nexport interface Logger {\r\n debug: (message: string, context?: LoggerContext) => void;\r\n info: (message: string, context?: LoggerContext) => void;\r\n warn: (message: string, context?: LoggerContext) => void;\r\n error: (message: string, error?: unknown, context?: LoggerContext) => void;\r\n}\r\n\r\n//? Prefix a log message with an ISO-8601 UTC timestamp when\r\n//? `logging.timestamps` is on (the default). Read at call time so a consumer's\r\n//? config registration order doesn't matter, and so it can be toggled off under\r\n//? a log aggregator that stamps its own time. Only the framework's BUILT-IN\r\n//? loggers use this — a registered custom logger owns its own formatting.\r\nconst withTimestamp = (message: string): string =>\r\n getProjectConfig().logging.timestamps ? `[${new Date().toISOString()}] ${message}` : message;\r\n\r\nconst defaultLogger: Logger = {\r\n debug: (message, context) => {\r\n if (context === undefined) {console.debug(withTimestamp(message));}\r\n else {console.debug(withTimestamp(message), context);}\r\n },\r\n info: (message, context) => {\r\n if (context === undefined) {console.info(withTimestamp(message));}\r\n else {console.info(withTimestamp(message), context);}\r\n },\r\n warn: (message, context) => {\r\n if (context === undefined) {console.warn(withTimestamp(message));}\r\n else {console.warn(withTimestamp(message), context);}\r\n },\r\n error: (message, error, context) => {\r\n if (error !== undefined && context !== undefined) console.error(withTimestamp(message), error, context);\r\n else if (error !== undefined) console.error(withTimestamp(message), error);\r\n else if (context === undefined) {console.error(withTimestamp(message));}\r\n else {console.error(withTimestamp(message), context);}\r\n },\r\n};\r\n\r\n//? Dev-mode logger factory: wraps the default behavior with the colored\r\n//? terminal output that `initConsolelog()` produces. Install via\r\n//? `registerLogger(createDevLogger())` from a dev-only entry. Note: this\r\n//? still depends on `initConsolelog()` having been called to interpret the\r\n//? trailing color string — without it, the codes will appear as literal\r\n//? text. Use the default logger when running in production or unsure.\r\nexport const createDevLogger = (): Logger => ({\r\n debug: (message, context) => {\r\n if (context === undefined) {console.log(withTimestamp(message), 'cyan');}\r\n else {console.log(withTimestamp(message), context, 'cyan');}\r\n },\r\n info: (message, context) => {\r\n if (context === undefined) {console.log(withTimestamp(message));}\r\n else {console.log(withTimestamp(message), context);}\r\n },\r\n warn: (message, context) => {\r\n if (context === undefined) {console.log(withTimestamp(message), 'yellow');}\r\n else {console.log(withTimestamp(message), context, 'yellow');}\r\n },\r\n error: (message, error, context) => {\r\n if (error !== undefined && context !== undefined) console.log(withTimestamp(message), error, context, 'red');\r\n else if (error !== undefined) console.log(withTimestamp(message), error, 'red');\r\n else if (context === undefined) {console.log(withTimestamp(message), 'red');}\r\n else {console.log(withTimestamp(message), context, 'red');}\r\n },\r\n});\r\n\r\nconst registry = createRegistry<Logger>(defaultLogger);\r\n\r\nexport const registerLogger = (logger: Logger): void => {\r\n registry.register(logger);\r\n};\r\n\r\nexport const getLogger = (): Logger => registry.get();\r\n\r\nexport const isLoggerRegistered = (): boolean => registry.isRegistered();\r\n\r\n//? Test-only helper — restore the default logger between integration tests.\r\n//? Never call from production code.\r\nexport const resetLoggerForTests = (): void => {\r\n registry.reset();\r\n};\r\n","//? Legacy Sentry single-instance slot. Predates the multi-adapter\r\n//? ErrorTracker registry. New code should `registerErrorTracker(...)`\r\n//? from `@luckystack/error-tracking`; this file keeps backwards\r\n//? compatibility AND fans out events through any registered adapters so\r\n//? a project can mix legacy `initSharedSentry` + new `registerErrorTracker`\r\n//? during migration.\r\n//?\r\n//? The framework's own call sites (`captureException`, `captureMessage`,\r\n//? `setSentryUser`, `startSpan`) keep working unchanged — they now route\r\n//? through both the legacy slot and the new adapter list.\r\n\r\nimport {\r\n captureExceptionAcrossTrackers,\r\n captureMessageAcrossTrackers,\r\n setErrorTrackerUser,\r\n startSpanAcrossTrackers,\r\n} from './errorTrackerRegistry';\r\nimport { sanitizeForLog } from './redactedLogKeys';\r\n\r\ninterface SentryInstance {\r\n captureException: (exception: unknown, ...args: unknown[]) => string;\r\n captureMessage: (message: string, ...args: unknown[]) => string;\r\n setUser: (user: unknown) => void;\r\n setContext: (key: string, context: unknown) => void;\r\n startInactiveSpan?: (context: unknown) => unknown;\r\n}\r\n\r\nlet sentry: SentryInstance | undefined;\r\n\r\n/**\r\n * @deprecated Use `registerErrorTracker` from `@luckystack/error-tracking` instead.\r\n * Will be removed in a future major version.\r\n */\r\nexport const initSharedSentry = (instance: SentryInstance): void => {\r\n sentry = instance;\r\n};\r\n\r\n//? Pass `extra` inline via Sentry's hint-shaped second argument so contexts\r\n//? don't leak across concurrent captures. Previously we called\r\n//? `sentry.setContext('additional', context)` which is process-global until\r\n//? cleared — under concurrent captures the first context could leak into the\r\n//? second's report.\r\n//? CORE-O4: sanitize context before passing to the legacy Sentry slot so raw\r\n//? tokens/passwords don't reach `extra` when a consumer mixes initSharedSentry\r\n//? with framework capture. The multi-adapter path already sanitizes in\r\n//? errorTrackerRegistry.ts; this brings the legacy path into parity.\r\nexport const captureException = (\r\n error: unknown,\r\n context?: Record<string, unknown>,\r\n): void => {\r\n if (sentry) {\r\n if (context) {\r\n sentry.captureException(error, { extra: sanitizeForLog(context) as Record<string, unknown> });\r\n } else {\r\n sentry.captureException(error);\r\n }\r\n }\r\n //? Multi-adapter fan-out. When no adapter is registered this is a no-op.\r\n captureExceptionAcrossTrackers(error, context);\r\n};\r\n\r\nexport const captureMessage = (\r\n message: string,\r\n level: 'info' | 'warning' | 'error' | 'fatal' = 'info',\r\n context?: Record<string, unknown>,\r\n): void => {\r\n if (sentry) {\r\n if (context) {\r\n sentry.captureMessage(message, { level, extra: sanitizeForLog(context) as Record<string, unknown> });\r\n } else {\r\n sentry.captureMessage(message, level);\r\n }\r\n }\r\n captureMessageAcrossTrackers(message, level, context);\r\n};\r\n\r\nexport const setSentryUser = (user: {\r\n id?: string;\r\n email?: string;\r\n username?: string;\r\n} | null): void => {\r\n sentry?.setUser(user);\r\n setErrorTrackerUser(user);\r\n};\r\n\r\nexport const startSpan = (name: string, op: string): unknown => {\r\n //? Multi-adapter span coordination: first registered adapter that\r\n //? supports spans wins (spans don't fan out cleanly). Legacy Sentry\r\n //? single-instance path still creates the inactive span for backwards\r\n //? compatibility on consumers that haven't migrated.\r\n if (sentry?.startInactiveSpan) {\r\n return sentry.startInactiveSpan({ name, op });\r\n }\r\n //? No-op span body is intentional — this is the legacy inactive-span shape\r\n //? consumers built against; the real span lifecycle is managed elsewhere.\r\n // eslint-disable-next-line @typescript-eslint/no-empty-function -- legacy inactive-span shape\r\n startSpanAcrossTrackers(name, op, () => {});\r\n};\r\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/configUtils.ts","../src/createRegistry.ts","../src/projectConfig.ts"],"sourcesContent":["//? Shared config-merge utilities. Every `@luckystack/*` config registry\r\n//? (projectConfig, avatarConfig, and — after this lands — email/presence/\r\n//? error-tracking) deep-merges a consumer's deep-partial override over a\r\n//? built-in default. This module is the single implementation so a merge\r\n//? bug is fixed once for everyone.\r\n//?\r\n//? Behaviour contract (must match the historical hand-rolled copies exactly):\r\n//? - `override === undefined` → return `base` untouched.\r\n//? - When either side is not a plain object (arrays, primitives, class\r\n//? instances, null) → the override replaces the base wholesale\r\n//? (`override ?? base`). Arrays are therefore replaced, never merged.\r\n//? - Otherwise merge key-by-key: `undefined` override values are skipped\r\n//? (the base value wins), nested plain objects recurse, everything else\r\n//? replaces.\r\n//?\r\n//? Added over the historical copies: a depth + circular-reference guard so a\r\n//? pathological override (self-referential object or absurd nesting) degrades\r\n//? to a wholesale replace instead of overflowing the stack. Real config trees\r\n//? are a handful of levels deep, so the guard never triggers in practice and\r\n//? behaviour is identical to the old implementations.\r\n\r\n/** Maximum recursion depth before the merge stops descending and replaces wholesale. */\r\nconst MAX_MERGE_DEPTH = 100;\r\n\r\nexport type DeepPartial<T> = {\r\n [K in keyof T]?: T[K] extends object | undefined ? DeepPartial<NonNullable<T[K]>> : T[K];\r\n};\r\n\r\n/**\r\n * True when `value` is a plain object literal (prototype is `Object.prototype`\r\n * or `null`). Arrays, class instances, Maps, Dates, and `null` are rejected so\r\n * they are treated as opaque replacement values during a merge.\r\n */\r\nexport const isPlainObject = (value: unknown): value is Record<string, unknown> => {\r\n if (value === null || typeof value !== 'object') return false;\r\n const proto = Object.getPrototypeOf(value) as object | null;\r\n return proto === Object.prototype || proto === null;\r\n};\r\n\r\nconst deepMergeInternal = <T>(\r\n base: T,\r\n override: DeepPartial<T> | undefined,\r\n depth: number,\r\n seen: WeakSet<object>,\r\n): T => {\r\n if (override === undefined) return base;\r\n if (!isPlainObject(base) || !isPlainObject(override)) {\r\n return (override as T) ?? base;\r\n }\r\n\r\n //? Depth / circular guard: if we have descended too far, or the override\r\n //? object has already been visited on this branch (self-reference), stop\r\n //? recursing and replace wholesale. Normal config trees never reach this.\r\n if (depth >= MAX_MERGE_DEPTH || seen.has(override)) {\r\n return (override as T) ?? base;\r\n }\r\n seen.add(override);\r\n try {\r\n const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };\r\n for (const [key, overrideValue] of Object.entries(override as Record<string, unknown>)) {\r\n if (overrideValue === undefined) continue;\r\n //? Prototype-pollution guard: only `__proto__` can silently mutate the\r\n //? prototype chain via object spread/assign. `constructor` and `prototype`\r\n //? are valid config key names (e.g. a config section named `prototype` or a\r\n //? field called `constructor`) and must be allowed through; blocking them\r\n //? silently dropped legitimate consumer config keys (CORE-N5).\r\n if (key === '__proto__') continue;\r\n const baseValue = (base as Record<string, unknown>)[key];\r\n result[key] =\r\n isPlainObject(baseValue) && isPlainObject(overrideValue)\r\n ? deepMergeInternal(baseValue, overrideValue as DeepPartial<unknown>, depth + 1, seen)\r\n : overrideValue;\r\n }\r\n return result as T;\r\n } finally {\r\n //? Remove on the way back UP so the visited-set tracks only the ACTIVE branch\r\n //? (ancestor cycle), not the whole tree. Without this, a shared (DAG) object\r\n //? reference reused at two sibling config positions hit `seen.has()` on its\r\n //? 2nd occurrence and was replaced wholesale — silently dropping that\r\n //? sub-tree's base defaults (could relax a security default). Per-branch\r\n //? tracking still catches a genuine self-reference (object on the live path).\r\n seen.delete(override);\r\n }\r\n};\r\n\r\n/**\r\n * Deep-merge a deep-partial `override` over a fully-populated `base`,\r\n * returning a new object. See the module header for the exact behaviour\r\n * contract. Arrays and non-plain-object values are replaced wholesale; nested\r\n * plain objects merge recursively; `undefined` override values are ignored.\r\n */\r\nexport const deepMerge = <T>(base: T, override: DeepPartial<T> | undefined): T =>\r\n deepMergeInternal(base, override, 0, new WeakSet<object>());\r\n","//? Generic single-slot DI-registry factory. LuckyStack's `register* / get* /\n//? is*Registered / reset*ForTests` triad is hand-rolled a dozen-plus times in\n//? this package (and again downstream in `@luckystack/server`). Each copy is\n//? the same shape: a module-level mutable slot, a setter that flips a\n//? \"registered\" flag, a call-time getter, a boolean guard, and an optional\n//? test-reset. This factory centralises that boilerplate while staying\n//? flexible enough for the variants that exist in the wild:\n//?\n//? - registers that return the stored value (deployConfig, servicesConfig)\n//? vs. return void (projectConfig, logger, notifier).\n//? - getters that fall back to a default vs. throw when nothing is\n//? registered (servicesConfig).\n//? - a debug-log line on each register (rateLimiter strategy).\n//? - an optional input → stored transform (projectConfig deep-merges the\n//? consumer's deep-partial over the defaults before storing).\n//?\n//? Registries with genuinely bespoke storage (keyed slots like the\n//? prisma/redis client map, additive Set-based registries, multi-handler\n//? arrays) are intentionally NOT a fit for this single-slot factory and stay\n//? hand-rolled.\n\n//? When no `transform` is supplied, `TInput` must equal `TStored` so the\n//? identity store is type-safe (no cast). When they differ, a `transform` is\n//? mandatory — `RequireTransform` makes that a compile error rather than a\n//? runtime surprise.\ntype RequireTransform<TInput, TStored> = [TInput] extends [TStored]\n ? { transform?: (input: TInput, current: TStored) => TStored }\n : { transform: (input: TInput, current: TStored) => TStored };\n\ninterface RegistryOptionsBase<TStored> {\n /**\n * Called once per `register` with the freshly-stored value — used for the\n * rate-limiter's `[RateLimiter] active strategy → …` debug line. Runs after\n * the slot is updated so a logger registry can log through itself.\n */\n onRegister?: (stored: TStored) => void;\n /**\n * Resolve the value `get` returns when nothing has been registered yet.\n * Defaults to returning `defaultValue`. `servicesConfig` throws here.\n */\n resolveDefault?: () => TStored;\n}\n\nexport type RegistryOptions<TInput, TStored> = RegistryOptionsBase<TStored> &\n RequireTransform<TInput, TStored>;\n\nexport interface Registry<TInput, TStored> {\n /** Store a value (after the optional `transform`) and mark the slot registered. */\n register: (input: TInput) => TStored;\n /** Read the active value at call time (registered value, else the default). */\n get: () => TStored;\n /** True once `register` has been called at least once. */\n isRegistered: () => boolean;\n /** Restore the unregistered state (test-only). */\n reset: () => void;\n}\n\n/**\n * Create a single-slot registry. `defaultValue` is returned by `get` until\n * `register` is called (unless `resolveDefault` overrides that). When\n * `TInput` differs from `TStored`, the `transform` option is mandatory (the\n * `RequireTransform` constraint enforces it); when they match it is optional\n * and defaults to identity. The second positional argument is likewise\n * optional only in the identity case.\n */\nexport function createRegistry<TStored, TInput = TStored>(\n defaultValue: TStored,\n ...optionsArg: [TInput] extends [TStored]\n ? [options?: RegistryOptions<TInput, TStored>]\n : [options: RegistryOptions<TInput, TStored>]\n): Registry<TInput, TStored> {\n //? Implementation body uses a single erased `unknown` boundary so the\n //? identity fallback needs no per-instantiation cast. The public overload\n //? above keeps every call site fully typed; this boundary is internal only.\n const options = (optionsArg[0] ?? {}) as {\n transform?: (input: unknown, current: unknown) => unknown;\n onRegister?: (stored: unknown) => void;\n resolveDefault?: () => unknown;\n };\n const { transform, onRegister, resolveDefault } = options;\n\n let active: unknown = defaultValue;\n let registered = false;\n\n const register = (input: TInput): TStored => {\n active = transform ? transform(input, active) : input;\n registered = true;\n if (onRegister) onRegister(active);\n return active as TStored;\n };\n\n const get = (): TStored => {\n if (registered) return active as TStored;\n return (resolveDefault ? resolveDefault() : defaultValue) as TStored;\n };\n\n const isRegistered = (): boolean => registered;\n\n const reset = (): void => {\n active = defaultValue;\n registered = false;\n };\n\n return { register, get, isRegistered, reset };\n}\n","//? Project-level runtime configuration, consumed by framework packages via\r\n//? `getProjectConfig()`. The project's entrypoint calls\r\n//? `registerProjectConfig({...})` once at boot — before any framework code\r\n//? issues API/sync requests — mirroring the pattern we use for the localized\r\n//? normalizer.\r\n//?\r\n//? Everything here is runtime-settable. Type-level project shapes\r\n//? (`SessionLayout`, etc.) stay in the project's `config.ts` so TypeScript\r\n//? inference keeps working at call sites.\r\n//?\r\n//? Framework code reads values through `getProjectConfig()` (call-time) so\r\n//? `registerProjectConfig` can be invoked after this module is imported.\r\n//? Never read at module load — that captures whatever was registered at\r\n//? import time, which is fragile.\r\n//?\r\n//? `registerProjectConfig` accepts a deep-partial of `ProjectConfig`; missing\r\n//? fields fall back to the defaults defined here. This means a project's\r\n//? `config.ts` only has to specify the values it wants to override.\r\n\r\nimport { deepMerge, type DeepPartial } from './configUtils';\r\nimport { createRegistry } from './createRegistry';\r\n\r\nexport interface LoggingConfig {\r\n devLogs: boolean;\r\n devNotifications: boolean;\r\n socketStatus: boolean;\r\n socketStartup: boolean;\r\n stream: boolean;\r\n /**\r\n * Prefix every line from the framework's built-in loggers (the default\r\n * `console.*` logger and `createDevLogger`) with an ISO-8601 UTC timestamp,\r\n * e.g. `[2026-07-13T15:20:01.123Z] Connected to Redis`. DEFAULT `true`. Set\r\n * `false` under a log aggregator that stamps its own time (journald, Docker,\r\n * Datadog) to avoid double timestamps. A registered custom logger\r\n * (`registerLogger(...)`) is responsible for its own timestamps — this flag\r\n * only governs the built-in loggers.\r\n */\r\n timestamps: boolean;\r\n}\r\n\r\nexport interface RateLimitingConfig {\r\n /**\r\n * Global kill-switch for all rate limiting. When `false`, every\r\n * `checkRateLimit` call short-circuits to \"allowed\" and counters are not\r\n * touched. Useful for local dev, load tests, or trusted internal tooling.\r\n * Defaults to `true`.\r\n */\r\n enabled: boolean;\r\n store: 'memory' | 'redis';\r\n redisKeyPrefix: string;\r\n defaultApiLimit: number | false;\r\n defaultIpLimit: number | false;\r\n windowMs: number;\r\n /** How often the in-memory store evicts expired entries. */\r\n cleanupIntervalMs: number;\r\n /**\r\n * What the default strategy does when `store: 'redis'` is active but a Redis\r\n * operation fails (disconnect, eval error). DEFAULT `'memory'` — degrade to\r\n * the process-local in-memory store (fail-open, backwards-compatible) and log\r\n * a one-shot warning. In a multi-instance deployment this silently relaxes the\r\n * global limit to per-instance (N×). Set `'deny'` to instead reject the\r\n * request (fail-closed) so a Redis outage can't bypass rate limiting.\r\n */\r\n onStoreError: 'memory' | 'deny';\r\n /**\r\n * Skip rate limiting for requests that resolve to a loopback client IP\r\n * (`127.0.0.1`, `::1`, the `UNKNOWN_CLIENT_IP` sentinel) when running in\r\n * development (`NODE_ENV !== 'production'`). Lets a dev machine hammer its own\r\n * server (HMR reload storms, local load tests) without tripping limits, while\r\n * staying fully enforced in production. DEFAULT `false` (enforce everywhere) —\r\n * a missing key keeps today's behavior. The api/sync transport handlers read\r\n * this when building the per-IP key; core only owns the flag + the\r\n * {@link isLoopbackIp} helper.\r\n */\r\n skipLoopbackInDev: boolean;\r\n /**\r\n * Identity callback that decides the BASIS of a rate-limit key for a given\r\n * request — i.e. whether to key per-user, per-IP, or some custom dimension.\r\n * The api/sync handlers call this (when set) to derive the `{ scope, id }`\r\n * used to build the bucket key, instead of the built-in user-then-IP default.\r\n * Returning `null` falls back to the framework default. Synchronous by design\r\n * (it runs on the hot request path). DEFAULT unset.\r\n */\r\n identity?: (params: RateLimitIdentityParams) => RateLimitIdentity | null;\r\n /**\r\n * Per-account brute-force lockout slot, keyed independently from the general\r\n * api/ip limits so credential endpoints (login, password-reset) can apply a\r\n * stricter window without affecting normal traffic.\r\n *\r\n * NOTE — DD-CORE-D3: **core does NOT consume this slot**. The core package\r\n * only declares and stores the config; the `@luckystack/login` package reads\r\n * `rateLimiting.auth` when keying a per-account attempt counter\r\n * (`auth:<accountKey>`). If you search the core source for usages of this\r\n * field and find none, that is expected — it is not dead config; it is login-\r\n * owned config that lives here for a single source of truth.\r\n *\r\n * DEFAULT `{ enabled: false }` — a missing/disabled slot keeps today's\r\n * behavior (no per-account lockout; only the per-IP throttle applies).\r\n */\r\n auth: AuthRateLimitConfig;\r\n}\r\n\r\n/** Inputs handed to the {@link RateLimitingConfig.identity} callback. */\r\nexport interface RateLimitIdentityParams {\r\n /** Resolved route name (`api/billing/getInvoice/v1`, `sync/...`). */\r\n routeName: string;\r\n /** Session user id when the request is authenticated, else null. */\r\n userId: string | null;\r\n /** Resolved client IP (already honours `http.trustProxy`). */\r\n ip: string;\r\n /** Transport the request arrived on. */\r\n transport: 'socket' | 'http';\r\n}\r\n\r\n/** Result of a {@link RateLimitingConfig.identity} callback. */\r\nexport interface RateLimitIdentity {\r\n /** Which dimension the key is built from. */\r\n scope: 'user' | 'ip' | 'custom';\r\n /** The identity value (user id, ip, or a custom string). Never a raw token. */\r\n id: string;\r\n}\r\n\r\nexport interface AuthRateLimitConfig {\r\n /** When false (default), no per-account lockout is applied. */\r\n enabled: boolean;\r\n /** Max failed attempts per account FROM ONE IP within `windowMs` before that\r\n * IP+account bucket locks (per-IP cap; also bounds a single IP and protects other\r\n * IPs from a victim-lock DoS). Default 5. */\r\n maxAttempts: number;\r\n /**\r\n * Cross-IP cap: max failed attempts against a SINGLE account from ALL IPs\r\n * combined within `windowMs` before the account locks. This is the\r\n * distributed-credential-stuffing defense — `maxAttempts` alone is per-IP, so N\r\n * attacker IPs would otherwise each get a fresh `maxAttempts` budget. Should be\r\n * HIGHER than `maxAttempts` (it counts every IP). Default 50.\r\n */\r\n maxAttemptsPerAccount: number;\r\n /** Rolling window in ms over which both caps are counted. Default 900000 (15 min). */\r\n windowMs: number;\r\n}\r\n\r\nexport interface SessionConfig {\r\n basedToken: boolean;\r\n expiryDays: number;\r\n /**\r\n * Whether a single user can hold multiple active sessions across devices.\r\n * `'single'` (default): logging in on a new device kicks the previous one.\r\n * `'multiple'`: device A and device B can both be logged in concurrently.\r\n */\r\n perUser: 'single' | 'multiple';\r\n /**\r\n * Optional cap on concurrent sessions when `perUser === 'multiple'`. Null\r\n * = unlimited. When the cap is reached, the policy from `onConflict`\r\n * decides whether the new login is accepted (kicking oldest) or rejected.\r\n */\r\n maxConcurrentPerUser: number | null;\r\n /**\r\n * What to do when a session limit is exceeded.\r\n * `'revokeOld'` (default): kick the oldest session, accept the new login.\r\n * `'rejectNew'`: refuse the new login, leave existing sessions intact.\r\n */\r\n onConflict: 'revokeOld' | 'rejectNew';\r\n /**\r\n * When `onConflict === 'revokeOld'`, whether to broadcast a short UI\r\n * notification (\"logged in elsewhere\") to the kicked device before\r\n * disconnecting it. Default true.\r\n */\r\n notifyOldDeviceOnRevoke: boolean;\r\n /**\r\n * Prefix used for Redis session/activeUsers keys (e.g. `${projectName}-session:<token>`).\r\n * Falls back to `process.env.PROJECT_NAME` then this default at config build time.\r\n */\r\n projectName: string;\r\n}\r\n\r\nexport interface AppConfig {\r\n /**\r\n * Public URL of the app — used by OAuth callback redirects, transactional\r\n * email links (when `@luckystack/email` is installed), and any other\r\n * framework code that needs to render an absolute link to your app.\r\n * Empty default — consumers should set this to the public origin of their\r\n * deployment (e.g. `https://app.example.com`).\r\n */\r\n publicUrl: string;\r\n}\r\n\r\nexport interface HttpStreamConfig {\r\n /** Query-string flag clients pass to opt into SSE streaming. */\r\n queryParam: string;\r\n /** Value the query-string flag must have. */\r\n enabledValue: string;\r\n /** Initial comment line written when an SSE stream opens (keeps proxies happy). */\r\n connectedComment: string;\r\n}\r\n\r\nexport interface SecurityHeadersConfig {\r\n frameOptions: string;\r\n referrerPolicy: string;\r\n xssProtection: string;\r\n contentTypeOptions: string;\r\n}\r\n\r\nexport interface CorsConfig {\r\n allowedMethods: string;\r\n allowedHeaders: string;\r\n exposedHeaders: string;\r\n /** Send `Access-Control-Allow-Credentials: true`. */\r\n credentials: boolean;\r\n /**\r\n * Origins permitted to make cross-origin requests. The framework always\r\n * permits the `SERVER_IP:SERVER_PORT` it binds to. Set `allowLocalhost`\r\n * to true to also accept any `localhost` origin (useful for dev).\r\n * Origins are normalized (scheme + host + port) before comparison.\r\n *\r\n * Static list mode: pass a `string[]`. Origins are matched exactly.\r\n * Dynamic mode: pass a synchronous function `(origin) => boolean` for\r\n * per-tenant / per-time-window allow-listing. Async resolvers are NOT\r\n * supported here because Socket.io's CORS check is synchronous; use a\r\n * pre-computed in-memory cache that the function can read from.\r\n */\r\n allowedOrigins: string[] | ((origin: string) => boolean);\r\n /**\r\n * Accept any origin matching `localhost` (any port). Convenient for local\r\n * development; should be `false` in production. Defaults to `false` so\r\n * production deployments fail closed.\r\n */\r\n allowLocalhost: boolean;\r\n /**\r\n * Historical opt-in kept for symmetry. Origin-less requests are ALWAYS\r\n * admitted at the CORS layer now (see `loadSocket.ts`): browsers omit the\r\n * `Origin` header on same-origin GETs, which is exactly the initial Socket.io\r\n * polling handshake in both dev (Vite proxy) and prod-with-router (single\r\n * origin) topologies — rejecting it broke every connection with\r\n * `400 code:3 MIDDLEWARE_FAILURE`. The real auth gate is the session token\r\n * in the handshake, not the Origin header. This flag no longer gates\r\n * anything and is retained only so existing configs that set it keep\r\n * type-checking; it may be removed in a future major version.\r\n */\r\n allowOriginless?: boolean;\r\n /**\r\n * Optional per-route override hook. When a `_api/*` file exports\r\n * `export const cors = { allowedOrigins, allowedHeaders, ... }` the\r\n * framework merges that on top of the global config for THAT route only.\r\n * Implemented in the api handler — this field is documentation-only.\r\n */\r\n // Note: per-route override is wired in `packages/api/src/handleHttpApiRequest.ts`.\r\n}\r\n\r\nexport interface HttpConfig {\r\n sessionCookieName: string;\r\n sessionCookieSameSite: 'Strict' | 'Lax' | 'None';\r\n sessionCookiePath: string;\r\n /**\r\n * Optional `Domain` attribute for the session cookie (CORE-39). Leave unset\r\n * (DEFAULT) for a host-only cookie (most secure). Set to a parent domain\r\n * (`.example.com`) only when the cookie must be shared across subdomains.\r\n * IGNORED when `sessionCookiePrefix === '__Host-'` (that prefix forbids\r\n * `Domain`). A missing key keeps today's host-only behavior.\r\n */\r\n sessionCookieDomain?: string;\r\n /**\r\n * Optional cookie name prefix enforcing browser-level guarantees (CORE-10/39):\r\n *\r\n * - `'__Host-'` — forces `Secure`, `Path=/`, and NO `Domain` (blocks\r\n * subdomain cookie-tossing). The strongest option for a host-only session.\r\n * - `'__Secure-'` — forces `Secure` (cookie only sent over HTTPS).\r\n * - unset (DEFAULT) — no prefix; today's behavior. A missing key changes\r\n * nothing.\r\n *\r\n * The server's cookie builder (`buildSessionCookie`) reads this and applies\r\n * the prefix to `sessionCookieName` plus the forced attributes. Use\r\n * {@link applyCookiePrefixConstraints} to compute the effective attributes.\r\n */\r\n sessionCookiePrefix?: '__Host-' | '__Secure-';\r\n /**\r\n * Per-cookie `Secure` override (CORE-39). When unset (DEFAULT) the server\r\n * derives `Secure` from `process.env.SECURE` as today. Set explicitly to\r\n * force on/off independently of the env flag. A `__Host-`/`__Secure-` prefix\r\n * forces `Secure: true` regardless.\r\n */\r\n sessionCookieSecure?: boolean;\r\n /**\r\n * Maximum body size (bytes) accepted on the HTTP transport for `/api/*` and\r\n * `/sync/*` POSTs. DEFAULT 1 MiB (1024 * 1024). This is the HTTP-body cap; the\r\n * SOCKET transport has a SEPARATE, independently-configurable frame cap\r\n * (`socket.maxHttpBufferSize`, default 5 MiB) — the two are deliberately distinct\r\n * knobs (HTTP body parser vs Socket.io whole-frame buffer). Set both to the same\r\n * value if you want a uniform per-call payload ceiling across transports.\r\n */\r\n requestBodyMaxBytes: number;\r\n /** Path of the router boot-handshake endpoint. */\r\n healthEndpoint: string;\r\n /**\r\n * Liveness endpoint. Always returns 200 if the process is up and\r\n * responsive — does NOT check dependencies. Suitable for K8s `livenessProbe`.\r\n */\r\n liveEndpoint: string;\r\n /**\r\n * Readiness endpoint. Returns 200 only when Redis + Prisma are reachable\r\n * AND boot UUID is set. Suitable for K8s `readinessProbe`.\r\n */\r\n readyEndpoint: string;\r\n /** Path of the dev-only state-reset endpoint (gated by NODE_ENV + token). */\r\n testResetEndpoint: string;\r\n /**\r\n * Whether a known reverse proxy sits in front of this server. DEFAULT false.\r\n * When false, per-IP rate-limit keys derive from the raw transport peer\r\n * address (historical behaviour). When true, the framework resolves the real\r\n * client IP from `X-Forwarded-For` / `X-Real-IP` before keying — so per-IP\r\n * limits stay meaningful behind nginx/HAProxy. Only enable when a trusted\r\n * proxy populates those headers, otherwise clients can spoof their IP.\r\n */\r\n trustProxy?: boolean;\r\n /**\r\n * How many proxy hops to skip from the RIGHT of `X-Forwarded-For` when\r\n * `trustProxy` is on (CORE-O3). The rightmost entries are the ones appended by\r\n * YOUR trusted proxies, so the resolved client IP is the entry that many hops\r\n * in from the end. The LEFTMOST hop is client-controlled and must never be\r\n * trusted (it enables per-IP rate-limit evasion + audit-IP spoofing).\r\n *\r\n * DEFAULT 1 — with the standard single-trusted-proxy topology this selects the\r\n * immediate upstream peer (the rightmost real hop), i.e. the IP the proxy saw\r\n * the connection arrive from. Raise it to match the number of trusted proxies\r\n * in the chain. Clamped to the list length so an over-large count falls back\r\n * to the leftmost available trusted hop rather than the spoofable client entry.\r\n * IGNORED when `trustProxy` is false (raw peer address is used verbatim).\r\n */\r\n trustedProxyHopCount?: number;\r\n /**\r\n * Whether cookie-mode sessions (`session.basedToken === false`) also accept a\r\n * token supplied via `Authorization: Bearer` / `handshake.auth.token` as a\r\n * fallback (CORE-O10). DEFAULT false — in cookie-mode the framework reads ONLY\r\n * the session cookie and ignores any bearer/handshake-auth token, so a stolen\r\n * token replayed through a header can no longer defeat the cookie/CSRF model.\r\n * Set true to restore the legacy behaviour (cookie-then-bearer fallback).\r\n * IGNORED in token-mode (`session.basedToken === true`), which is unaffected:\r\n * it always reads the bearer/handshake token first, cookie as fallback.\r\n */\r\n acceptBearerInCookieMode?: boolean;\r\n /**\r\n * Controls how `/_health` exposes synchronized-env-var hashes (SEC-13).\r\n * Previously `/_health` returned an UNSALTED `sha256(value)` of each\r\n * synchronized secret, unauthenticated — enabling offline dictionary attacks\r\n * on low-entropy secrets + key-name disclosure. This config + the shared\r\n * {@link hashSynchronizedValue} helper let both the server (`/_health`) and the\r\n * router (boot handshake) salt/HMAC consistently so the compare still works.\r\n * DEFAULTS keep today's wire behavior (`mode: 'plain'`) so a missing key does\r\n * not break an existing router handshake — opt into `'salted'`/`'hmac'`\r\n * explicitly (and bump server + router together).\r\n */\r\n healthHash: HealthHashConfig;\r\n stream: HttpStreamConfig;\r\n securityHeaders: SecurityHeadersConfig;\r\n cors: CorsConfig;\r\n}\r\n\r\nexport interface HealthHashConfig {\r\n /**\r\n * - `'plain'` — unsalted `sha256(value)`; the pre-0.2.0 behavior, kept so an\r\n * existing router boot handshake keeps comparing successfully. Opt in with\r\n * `mode:'plain'` to restore the legacy wire output.\r\n * - `'salted'` — `sha256(salt + value)` using `salt` below (or the boot UUID\r\n * when `salt` is the literal `'@bootUuid'`). Stable across a boot, rotates on\r\n * restart when bound to the boot UUID.\r\n * - `'hmac'` (DEFAULT in 0.2.0) — `HMAC-SHA256(key=salt, value)`. The default\r\n * `salt:'@bootUuid'` keys it on the per-boot UUID server + router already\r\n * share, so `/_health` no longer exposes a stable, offline-attackable\r\n * `sha256(secret)`. Collapses to `'plain'` when no boot UUID is available so\r\n * the boot handshake never silently diverges.\r\n */\r\n mode: 'plain' | 'salted' | 'hmac';\r\n /**\r\n * Shared salt / HMAC key. Both the backend `/_health` and the router's\r\n * compare MUST use the same value. The literal `'@bootUuid'` is a sentinel\r\n * meaning \"use the current boot UUID as the salt\" (valid with `'salted'` or\r\n * `'hmac'`). DEFAULTS to `'@bootUuid'` in 0.2.0 (only consulted when\r\n * `mode !== 'plain'`); set a non-empty static value to pin a stable key.\r\n */\r\n salt: string;\r\n}\r\n\r\nexport interface PasswordPolicyConfig {\r\n /** Minimum total length. Default 8. */\r\n minLength: number;\r\n /** Maximum total length. Default 191 (DB column-friendly). */\r\n maxLength: number;\r\n /** Require at least one uppercase letter (A-Z). Default false. */\r\n requireUppercase: boolean;\r\n /** Require at least one lowercase letter (a-z). Default false. */\r\n requireLowercase: boolean;\r\n /** Require at least one digit (0-9). Default false. */\r\n requireNumber: boolean;\r\n /** Require at least one non-alphanumeric character. Default false. */\r\n requireSpecial: boolean;\r\n /**\r\n * Reject the 10k most common passwords (built-in list shipped with\r\n * `@luckystack/login`). Default true. Set to false in tests or when\r\n * onboarding from a legacy system that allowed weak passwords.\r\n */\r\n forbidCommon: boolean;\r\n /**\r\n * Optional consumer-supplied predicate. Return null when the password is\r\n * acceptable, or a string describing why it isn't (the string is used as\r\n * the i18n errorCode-style key surfaced to the client).\r\n */\r\n customValidator?: (password: string) => string | null;\r\n}\r\n\r\nexport interface AuthConfig {\r\n /**\r\n * Whether email+password (credentials) auth is enabled. When `true` (default)\r\n * `@luckystack/login/register` registers the credentials provider, so it shows\r\n * up in `GET /auth/providers` and the login form renders the email/password\r\n * fields. Set `false` for an OAuth-only app — the form hides the fields AND the\r\n * `/auth/api/credentials` route rejects (`auth.credentialsDisabled`).\r\n */\r\n credentials: boolean;\r\n /** TTL for OAuth state tokens stored in Redis. */\r\n oauthStateTtlSeconds: number;\r\n /**\r\n * @deprecated Read `passwordPolicy.minLength`. Kept as a top-level field\r\n * for older consumer code; new code should use the structured policy.\r\n */\r\n passwordMinLength: number;\r\n /**\r\n * @deprecated Read `passwordPolicy.maxLength`. Kept as a top-level field\r\n * for older consumer code; new code should use the structured policy.\r\n */\r\n passwordMaxLength: number;\r\n /** Full credentials password policy. Replaces the deprecated min/max-length pair. */\r\n passwordPolicy: PasswordPolicyConfig;\r\n /** Maximum email length for credentials auth. */\r\n emailMaxLength: number;\r\n /** Maximum display-name length for credentials auth. */\r\n nameMaxLength: number;\r\n /**\r\n * bcrypt cost factor used when hashing passwords (registration, password\r\n * change, password reset). Default 10 matches industry guidance for 2026\r\n * hardware. Bump to 12 for higher-value accounts; 4 is acceptable for tests\r\n * to keep them fast.\r\n */\r\n bcryptRounds: number;\r\n /**\r\n * `'per-provider'` (default) — same email via Google and GitHub creates\r\n * two separate User rows (current behavior, no schema change).\r\n * `'unified'` — same email maps to a single User; subsequent sign-ins via\r\n * different providers resolve to (link to) that same User row instead of\r\n * creating a duplicate. Requires `email` to be `@unique` — see the\r\n * \"Account strategy\" migration steps in @luckystack/login's README.\r\n */\r\n providerAccountStrategy: 'per-provider' | 'unified';\r\n /**\r\n * `'framework'` — login package ships the /reset-password pages + APIs and\r\n * sends the reset email. Requires a registered email sender.\r\n * `'custom'` — login exposes only the primitives\r\n * (`createPasswordResetToken`, `consumePasswordResetToken`,\r\n * `updatePasswordHash`); the consumer wires their own UI and emails.\r\n * `'disabled'` (default) — no forgot-password feature; the link in\r\n * `LoginForm` does not render.\r\n */\r\n forgotPassword: 'framework' | 'custom' | 'disabled';\r\n /** Reset-token TTL in seconds when `forgotPassword === 'framework'`. */\r\n passwordResetTtlSeconds: number;\r\n /**\r\n * Brand/display name used in framework-mode password-reset emails (subject +\r\n * greeting + footer). Falls back to `'LuckyStack'` if unset; consumers should\r\n * override this to their own brand.\r\n */\r\n passwordResetBrand?: string;\r\n /** Email-change confirmation-token TTL in seconds. Default 3600 (1 hour). */\r\n emailChangeTtlSeconds: number;\r\n /**\r\n * Whether public self-service registration is permitted (login F18). When\r\n * `false`, the credentials `/register` route rejects with\r\n * `auth.registrationDisabled` and the login UI hides the \"create account\"\r\n * affordance — useful for invite-only / admin-provisioned apps. OAuth-driven\r\n * first-login account creation is governed separately by the provider flow.\r\n * DEFAULT `true` (today's behavior — open registration).\r\n */\r\n allowRegistration: boolean;\r\n /**\r\n * Frontend path the framework-mode password-reset email links to (login F22).\r\n * The reset token is appended as a query param. DEFAULT `/reset-password`.\r\n * Override when your reset page lives at a different route.\r\n */\r\n passwordResetPath: string;\r\n /**\r\n * Frontend path the email-change confirmation email links to (login F22).\r\n * The confirmation token is appended as a query param.\r\n * DEFAULT `/confirm-email-change`.\r\n */\r\n emailChangeConfirmPath: string;\r\n /**\r\n * Passwordless email-code login (ADR 0024): the user enters their email,\r\n * receives a short numeric code, and signs in by typing it. Requires a\r\n * registered email sender (`@luckystack/email`). DEFAULT `false`.\r\n * Anti-enumeration: the request endpoint always answers \"sent\".\r\n */\r\n emailCodeLogin: boolean;\r\n /** Email-code TTL in seconds (login + 2FA fallback codes). DEFAULT 600. */\r\n emailCodeTtlSeconds: number;\r\n /** Email-code length in digits. DEFAULT 6. */\r\n emailCodeLength: number;\r\n /** Wrong-code attempts before an email code is burned. DEFAULT 5. */\r\n emailCodeMaxAttempts: number;\r\n /**\r\n * Second factor at login (ADR 0024). `'optional'` = per-user opt-in: a user\r\n * who enrolled an authenticator app (TOTP — Google/Microsoft Authenticator,\r\n * Authy, …) must answer a 2FA challenge after their password/email-code is\r\n * verified. `'disabled'` (DEFAULT) = the challenge step never triggers, even\r\n * for enrolled users (kill switch).\r\n */\r\n twoFactor: 'disabled' | 'optional';\r\n /**\r\n * Allow \"send the code to my email instead\" as a 2FA fallback channel for\r\n * enrolled users (lost phone). Requires a registered email sender.\r\n * DEFAULT `true` (recovery codes always work regardless).\r\n */\r\n twoFactorEmailFallback: boolean;\r\n /** Pending 2FA login-challenge TTL in seconds. DEFAULT 300. */\r\n twoFactorChallengeTtlSeconds: number;\r\n /** Wrong-code attempts before a pending 2FA challenge is burned. DEFAULT 5. */\r\n twoFactorMaxAttempts: number;\r\n}\r\n\r\nexport interface OfflineQueueConfig {\r\n /** Hard cap on items per queue (api + sync are tracked separately). Default 200. */\r\n maxSize: number;\r\n /** Drop items older than this many ms when flushing/enqueuing. Default 1 hour. */\r\n maxAgeMs: number;\r\n /**\r\n * What to do when the queue is full:\r\n * - 'drop-oldest' (default): evict the oldest item, append the new one.\r\n * - 'drop-newest': reject the new item, keep the existing queue.\r\n * - 'reject': do not enqueue; caller must handle the failure.\r\n */\r\n dropPolicy: 'drop-oldest' | 'drop-newest' | 'reject';\r\n}\r\n\r\nexport interface SyncStreamThrottleConfig {\r\n /** Default `flushAtChars` for `createStreamThrottle({...})`. */\r\n flushAtChars: number;\r\n /** Default `flushEveryMs` for `createStreamThrottle({...})`. */\r\n flushEveryMs: number | false;\r\n /** Default `field` (payload key) for emitted chunks. */\r\n field: string;\r\n}\r\n\r\nexport interface ApiConfig {\r\n /**\r\n * Default response timeout (ms) for `apiRequest`. After this elapses with no\r\n * response (e.g. server restart/crash between emit and reply) the request\r\n * settles with `{ status:'error', errorCode:'api.timeout', httpStatus:504 }`\r\n * instead of hanging forever. Set `false` to disable the timeout. A per-call\r\n * `timeoutMs` overrides this. Default 30000.\r\n */\r\n requestTimeoutMs: number | false;\r\n}\r\n\r\nexport interface ValidationConfig {\r\n /**\r\n * Whether runtime input validation runs in PRODUCTION (CORE-01).\r\n *\r\n * - `'enforce'` (DEFAULT) — the structural validator (`validateType`) runs\r\n * against the route's resolved input type in production too, so a malformed\r\n * payload is rejected with `api.invalidInputType` / `sync.invalidInputType`\r\n * instead of reaching the handler. Only the dev-only devkit DEEP type\r\n * resolver (TypeScript compiler API) is skipped in prod — the already-resolved\r\n * generated type text is validated directly.\r\n * - `'off'` — restore the legacy behavior where prod skips input validation\r\n * entirely (input shape is the handler's responsibility). This is the loud,\r\n * documented opt-out; set it only if a route's generated type text can't be\r\n * validated structurally in your deployment.\r\n *\r\n * DEFAULT `'enforce'`. Note: this CHANGES prior behavior (prod was a no-op).\r\n * Set `'off'` to keep the old no-op.\r\n */\r\n runtimeMode: 'enforce' | 'off';\r\n}\r\n\r\nexport interface SyncConfig {\r\n streamThrottle: SyncStreamThrottleConfig;\r\n /**\r\n * Default response timeout (ms) for `syncRequest`'s acknowledgement. Same\r\n * semantics as `api.requestTimeoutMs` but for sync. Settles with\r\n * `sync.requestTimeout` / httpStatus 504 on expiry. `false` to disable.\r\n * Default 30000.\r\n */\r\n requestTimeoutMs: number | false;\r\n /**\r\n * Yield to the event loop every N recipients during a broadcast fanout\r\n * (`receiver: 'all'` or large rooms). Lower = more responsive, higher\r\n * overhead. Default 100.\r\n */\r\n fanoutYieldEvery: number;\r\n /** Milliseconds to sleep when yielding. Default 1ms. */\r\n fanoutYieldMs: number;\r\n /**\r\n * Receiver-authorization policy (SYNC-07). These flags add framework-level\r\n * defaults read by `@luckystack/sync`'s `authorizeSyncReceiver`:\r\n *\r\n * - `allowClientReceiverAll` — when false, a client requesting the broadcast\r\n * receiver `'all'` is rejected unless a `preSyncAuthorize` handler approves\r\n * it. DEFAULT `false` (0.2.0 secure-default flip — a client can no longer\r\n * broadcast cluster-wide by default; opt back in with `true` for the legacy\r\n * permissive behavior).\r\n * - `requireRoomMembership` — when true, a client may only target a room it\r\n * has actually joined (its `roomCodes`); targeting an unjoined room is\r\n * rejected. DEFAULT `true` (0.2.0 secure-default flip — a client can no\r\n * longer fan out to a room it never joined; set `false` for the legacy\r\n * any-room behavior).\r\n *\r\n * BREAKING (0.2.0): both defaults now fail CLOSED. Apps that relied on\r\n * implicit cluster-wide / arbitrary-room broadcasts must either join the room\r\n * before targeting it, approve the receiver via a `preSyncAuthorize` handler,\r\n * or explicitly opt back into the permissive values.\r\n */\r\n allowClientReceiverAll: boolean;\r\n requireRoomMembership: boolean;\r\n /**\r\n * Stream backpressure tuning (SYNC-15) for `createStreamThrottle` /\r\n * server-initiated stream emits. Constants were previously hardcoded in the\r\n * sync package; surfacing them here lets a consumer tune flush cadence under\r\n * load without forking. DEFAULTS reproduce the historical hardcoded values.\r\n */\r\n flushPressure: SyncFlushPressureConfig;\r\n}\r\n\r\nexport interface SyncFlushPressureConfig {\r\n /** Max queued chunks before the stream applies backpressure (pauses). Default 1000. */\r\n highWaterMarkChunks: number;\r\n /** Resume emitting once the queue drains below this. Default 250. */\r\n lowWaterMarkChunks: number;\r\n /** Hard cap on bytes buffered for a single stream before dropping/erroring. Default 5_242_880 (5 MiB). */\r\n maxBufferedBytes: number;\r\n}\r\n\r\nexport interface SocketConfig {\r\n /**\r\n * Maximum payload size (bytes) for any single Socket.io message (whole-frame\r\n * buffer). DEFAULT 5 MiB (5 * 1024 * 1024). This is the SOCKET-transport cap; the\r\n * HTTP transport has a SEPARATE, independently-configurable body cap\r\n * (`http.requestBodyMaxBytes`, default 1 MiB). The two are intentionally distinct —\r\n * set both to the same value for a uniform per-call ceiling across transports.\r\n */\r\n maxHttpBufferSize: number;\r\n /** ms with no pong response before the server considers the client gone. */\r\n pingTimeout: number;\r\n /** ms between pings. */\r\n pingInterval: number;\r\n /**\r\n * Minimum ms between client → server `activity` heartbeats (repo-src C-CFG /\r\n * presence C1). The consumer template's activity tracker + `@luckystack/presence`\r\n * read this to throttle how often mouse/keyboard/touch activity is reported\r\n * (previously hardcoded to 10s). DEFAULT 10000 (10s) — a missing key keeps the\r\n * historical cadence.\r\n */\r\n activityHeartbeatThrottleMs: number;\r\n /**\r\n * Hard cap on how many rooms a single session may be joined to at once. When a\r\n * join would exceed the cap the OLDEST joined room is left first (FIFO eviction)\r\n * so `session.roomCodes` can't grow unbounded in Redis (session-bloat DoS).\r\n * DEFAULT 50. Set to `false` to disable the cap (unbounded — legacy behavior).\r\n */\r\n maxRoomsPerSession: number | false;\r\n}\r\n\r\nexport interface DevConfig {\r\n /** Debounce window for hot-reload triggers. */\r\n hotReloadDebounceMs: number;\r\n /** Chokidar `awaitWriteFinish.stabilityThreshold`. */\r\n watcherStabilityThresholdMs: number;\r\n /** Chokidar `awaitWriteFinish.pollInterval`. */\r\n watcherPollIntervalMs: number;\r\n /**\r\n * When true, log a warning the first time an api/sync route is invoked\r\n * without an `inputType` (typically because generated types haven't been\r\n * regenerated since the route was added). Helps catch routes that ship\r\n * with no runtime input validation. Default false (silent), set to true\r\n * in dev to surface missing types.\r\n */\r\n warnOnMissingInputType?: boolean;\r\n}\r\n\r\nexport interface PathsConfig {\r\n /** Frontend source root. */\r\n srcDir: string;\r\n /** Server-side source root. */\r\n serverDir: string;\r\n /** Shared (universal) source root. */\r\n sharedDir: string;\r\n /** Where uploaded user assets (avatars, etc.) live. */\r\n uploadsDir: string;\r\n /** Public static assets served as-is. */\r\n publicDir: string;\r\n /**\r\n * @deprecated Use `serverFunctionDirs` instead. Kept for backwards\r\n * compatibility with projects that set the singular form before the\r\n * function-injection system grew multi-directory support. When both are\r\n * present, `serverFunctionDirs` wins.\r\n */\r\n serverFunctionsDir: string;\r\n /**\r\n * Server-side function modules consumed by API/sync handlers. The\r\n * codegen and runtime walk every listed directory in order and merge the\r\n * results into one `Functions` interface / object. Duplicate keys\r\n * (same `<dirname>.<filename>.<export>` path produced by two roots)\r\n * fail the build — `shared/` is canonical, so the conflict tells the\r\n * consumer to delete the override.\r\n *\r\n * Default: `['functions', 'shared']`.\r\n *\r\n * Nested subdirectories work transparently — `functions/test/helper.ts`\r\n * with `export const foo = …` shows up as\r\n * `functions.test.helper.foo` on the injected `functions` parameter.\r\n */\r\n serverFunctionDirs: string[];\r\n /** Generated socket types output path (relative to project root). */\r\n generatedSocketTypes: string;\r\n /** Generated API input schemas output path. */\r\n generatedApiSchemas: string;\r\n /** Generated API docs JSON output path. */\r\n generatedApiDocs: string;\r\n}\r\n\r\nexport interface ProjectConfig {\r\n app: AppConfig;\r\n logging: LoggingConfig;\r\n rateLimiting: RateLimitingConfig;\r\n session: SessionConfig;\r\n http: HttpConfig;\r\n auth: AuthConfig;\r\n socket: SocketConfig;\r\n api: ApiConfig;\r\n sync: SyncConfig;\r\n validation: ValidationConfig;\r\n offlineQueue: OfflineQueueConfig;\r\n dev: DevConfig;\r\n paths: PathsConfig;\r\n defaultLanguage: string;\r\n /** Default theme for new users / fallback when no session preference exists. */\r\n defaultTheme?: 'light' | 'dark';\r\n /** Enable per-room activity broadcasting (presence). */\r\n socketActivityBroadcaster?: boolean;\r\n /** Show the floating socket-status indicator badge from `@luckystack/presence/client`. */\r\n socketStatusIndicator?: boolean;\r\n /** Enable client → server `updateLocation` syncing. */\r\n locationProviderEnabled?: boolean;\r\n /** Where to redirect the user after a successful OAuth callback. */\r\n loginRedirectUrl?: string;\r\n /**\r\n * Base origin used to build OAuth callback redirect URIs\r\n * (`<oauthCallbackBase>/auth/callback/<provider>`). This is the BACKEND\r\n * origin the provider redirects to — in dev typically `http://localhost:80`,\r\n * in prod the public domain. Read by `@luckystack/login/register`'s env-driven\r\n * provider scan so OAuth wiring needs no consumer code. Empty default — the\r\n * consumer's `config.ts` sets it.\r\n */\r\n oauthCallbackBase?: string;\r\n}\r\n\r\nexport type ProjectConfigInput = DeepPartial<ProjectConfig>;\r\n\r\nexport const DEFAULT_PROJECT_CONFIG: ProjectConfig = {\r\n app: {\r\n publicUrl: '',\r\n },\r\n logging: {\r\n devLogs: false,\r\n devNotifications: false,\r\n socketStatus: false,\r\n socketStartup: false,\r\n stream: false,\r\n timestamps: true,\r\n },\r\n rateLimiting: {\r\n enabled: true,\r\n store: 'memory',\r\n redisKeyPrefix: 'rate-limit',\r\n defaultApiLimit: 60,\r\n defaultIpLimit: 100,\r\n windowMs: 60_000,\r\n cleanupIntervalMs: 60_000,\r\n onStoreError: 'memory',\r\n skipLoopbackInDev: false,\r\n //? DD-CORE-D3: consumed exclusively by @luckystack/login, not by core itself.\r\n auth: {\r\n enabled: false,\r\n maxAttempts: 5,\r\n maxAttemptsPerAccount: 50,\r\n windowMs: 15 * 60 * 1000,\r\n },\r\n },\r\n session: {\r\n basedToken: false,\r\n expiryDays: 7,\r\n perUser: 'single',\r\n maxConcurrentPerUser: null,\r\n onConflict: 'revokeOld',\r\n notifyOldDeviceOnRevoke: true,\r\n //? Empty default — `getProjectName()` reads `process.env.PROJECT_NAME` at\r\n //? call time and falls back to `'luckystack'`. Avoids capturing env at\r\n //? module-load before dotenv runs.\r\n projectName: '',\r\n },\r\n http: {\r\n sessionCookieName: 'token',\r\n sessionCookieSameSite: 'Strict',\r\n sessionCookiePath: '/',\r\n requestBodyMaxBytes: 1024 * 1024,\r\n healthEndpoint: '/_health',\r\n liveEndpoint: '/livez',\r\n readyEndpoint: '/readyz',\r\n testResetEndpoint: '/_test/reset',\r\n trustProxy: false,\r\n //? CORE-O3: skip 1 hop from the RIGHT of X-Forwarded-For (the rightmost\r\n //? entry is appended by your own trusted proxy) instead of trusting the\r\n //? leftmost, client-controlled hop. Only consulted when `trustProxy` is on.\r\n trustedProxyHopCount: 1,\r\n //? CORE-O10 secure default: in cookie-mode, ignore any `Authorization: Bearer`\r\n //? / `handshake.auth.token` fallback so a stolen token cannot bypass the\r\n //? cookie/CSRF model. Set true to restore the legacy cookie-then-bearer\r\n //? fallback. Token-mode (`basedToken: true`) is unaffected.\r\n acceptBearerInCookieMode: false,\r\n //? 0.2.0 secure-default flip (SEC-13): `/_health` no longer exposes a stable,\r\n //? unsalted `sha256(secret)` fingerprint. Default mode is `'hmac'` keyed on\r\n //? the `'@bootUuid'` sentinel — the per-boot UUID both server + router already\r\n //? share via the boot handshake — so the synchronized-env hash rotates every\r\n //? restart and is no longer offline dictionary-attackable. Set an explicit\r\n //? non-empty `salt` to pin a stable HMAC key across restarts, or `mode:'plain'`\r\n //? to restore the legacy unsalted wire output.\r\n healthHash: {\r\n mode: 'hmac',\r\n salt: '@bootUuid',\r\n },\r\n stream: {\r\n queryParam: 'stream',\r\n enabledValue: 'true',\r\n connectedComment: ': connected',\r\n },\r\n securityHeaders: {\r\n frameOptions: 'SAMEORIGIN',\r\n referrerPolicy: 'no-referrer',\r\n //? '1; mode=block' is deprecated (Chrome 78+ ignores it; can trigger\r\n //? reflected-XSS in older IE/Edge auditors). '0' disables the legacy\r\n //? auditor and defers to CSP, which is the modern defence.\r\n xssProtection: '0',\r\n contentTypeOptions: 'nosniff',\r\n },\r\n cors: {\r\n allowedMethods: 'GET, POST, PUT, DELETE, OPTIONS',\r\n allowedHeaders: 'Content-Type, Authorization, X-Session-Based-Token, X-CSRF-Token, X-Request-Id',\r\n exposedHeaders: 'X-Session-Token, X-Request-Id',\r\n credentials: true,\r\n allowedOrigins: [],\r\n allowLocalhost: false,\r\n allowOriginless: false,\r\n },\r\n },\r\n auth: {\r\n credentials: true,\r\n oauthStateTtlSeconds: 60 * 10,\r\n passwordMinLength: 8,\r\n passwordMaxLength: 191,\r\n passwordPolicy: {\r\n minLength: 8,\r\n maxLength: 191,\r\n requireUppercase: false,\r\n requireLowercase: false,\r\n requireNumber: false,\r\n requireSpecial: false,\r\n forbidCommon: true,\r\n },\r\n emailMaxLength: 191,\r\n nameMaxLength: 191,\r\n bcryptRounds: 10,\r\n providerAccountStrategy: 'per-provider',\r\n forgotPassword: 'disabled',\r\n passwordResetTtlSeconds: 60 * 60,\r\n emailChangeTtlSeconds: 60 * 60,\r\n allowRegistration: true,\r\n passwordResetPath: '/reset-password',\r\n emailChangeConfirmPath: '/confirm-email-change',\r\n emailCodeLogin: false,\r\n emailCodeTtlSeconds: 60 * 10,\r\n emailCodeLength: 6,\r\n emailCodeMaxAttempts: 5,\r\n twoFactor: 'disabled',\r\n twoFactorEmailFallback: true,\r\n twoFactorChallengeTtlSeconds: 60 * 5,\r\n twoFactorMaxAttempts: 5,\r\n },\r\n socket: {\r\n maxHttpBufferSize: 5 * 1024 * 1024,\r\n pingTimeout: 20_000,\r\n pingInterval: 25_000,\r\n activityHeartbeatThrottleMs: 10_000,\r\n maxRoomsPerSession: 50,\r\n },\r\n api: {\r\n //? Default OFF (was 30_000): a legit handler may run for minutes (e.g. a deep\r\n //? image parse / long upstream call) and must not be aborted by a wall-clock\r\n //? race. A disconnecting client still aborts the handler via the wired\r\n //? abortSignal; set a number to opt back into a hard per-request ceiling\r\n //? (streaming requests are exempt even then — see runHttpApiExecution).\r\n requestTimeoutMs: false,\r\n },\r\n validation: {\r\n runtimeMode: 'enforce',\r\n },\r\n sync: {\r\n streamThrottle: {\r\n flushAtChars: 32,\r\n flushEveryMs: 50,\r\n field: 'chunk',\r\n },\r\n fanoutYieldEvery: 100,\r\n fanoutYieldMs: 1,\r\n requestTimeoutMs: 30_000,\r\n allowClientReceiverAll: false,\r\n requireRoomMembership: true,\r\n flushPressure: {\r\n highWaterMarkChunks: 1000,\r\n lowWaterMarkChunks: 250,\r\n maxBufferedBytes: 5 * 1024 * 1024,\r\n },\r\n },\r\n offlineQueue: {\r\n maxSize: 200,\r\n maxAgeMs: 60 * 60 * 1000,\r\n dropPolicy: 'drop-oldest',\r\n },\r\n dev: {\r\n hotReloadDebounceMs: 120,\r\n watcherStabilityThresholdMs: 120,\r\n watcherPollIntervalMs: 20,\r\n },\r\n paths: {\r\n srcDir: 'src',\r\n serverDir: 'server',\r\n sharedDir: 'shared',\r\n uploadsDir: 'uploads',\r\n publicDir: 'public',\r\n serverFunctionsDir: 'server/functions',\r\n serverFunctionDirs: ['functions', 'shared'],\r\n generatedSocketTypes: 'src/_sockets/apiTypes.generated.ts',\r\n generatedApiSchemas: 'src/_sockets/apiInputSchemas.generated.ts',\r\n generatedApiDocs: 'src/docs/apiDocs.generated.json',\r\n },\r\n defaultLanguage: 'en',\r\n defaultTheme: 'light',\r\n socketActivityBroadcaster: false,\r\n socketStatusIndicator: false,\r\n locationProviderEnabled: false,\r\n loginRedirectUrl: '/',\r\n oauthCallbackBase: '',\r\n};\r\n\r\nconst registry = createRegistry<ProjectConfig, ProjectConfigInput>(DEFAULT_PROJECT_CONFIG, {\r\n //? Always merge the consumer override over the pristine defaults (not the\r\n //? previously-stored config) — matches the historical register behaviour\r\n //? where each call rebuilds from `DEFAULT_PROJECT_CONFIG`.\r\n transform: (input) => deepMerge(DEFAULT_PROJECT_CONFIG, input),\r\n});\r\n\r\nexport const registerProjectConfig = (config: ProjectConfigInput): void => {\r\n registry.register(config);\r\n};\r\n\r\nexport const getProjectConfig = (): ProjectConfig => registry.get();\r\n\r\n//? Resolve the project namespace at call time. Single source of truth used\r\n//? for Redis key prefixes (`<projectName>-session:`, `-activeUsers:`,\r\n//? `-pwreset:`, `-oauth-state:`, etc.) across session.ts, logout.ts,\r\n//? rateLimiter.ts, passwordReset.ts, login.ts, testResetRoute.ts.\r\n//?\r\n//? Resolution order (first non-empty wins):\r\n//? 1. `projectConfig.session.projectName` (if a consumer set it explicitly)\r\n//? 2. `process.env.PROJECT_NAME` (read at call time — works after dotenv)\r\n//? 3. literal `'luckystack'` as the absolute fallback\r\nexport const getProjectName = (): string => {\r\n const fromConfig = registry.get().session.projectName;\r\n if (fromConfig && fromConfig.length > 0) return fromConfig;\r\n const fromEnv = process.env.PROJECT_NAME;\r\n if (fromEnv && fromEnv.length > 0) return fromEnv;\r\n return 'luckystack';\r\n};\r\n\r\n//? Guard for code paths that MUST run after registration (e.g. the server's\r\n//? startup sequence). Log once, never throw — framework packages should\r\n//? still do something reasonable when called in a test or CLI context that\r\n//? never registered.\r\nexport const isProjectConfigRegistered = (): boolean => registry.isRegistered();\r\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/apiMethodMapRegistry.ts","../src/httpApiUtils.ts","../src/socketState.ts","../src/notifier.ts","../src/tryCatchSync.ts","../src/clientHookBus.ts","../src/offlineQueue.ts","../src/responseNormalizer.ts","../src/serviceRoute.ts","../src/socketEvents.ts","../src/sleep.ts","../src/csrfConfig.ts","../src/tryCatch.ts","../src/csrf.ts","../src/pageRouteValidation.ts"],"sourcesContent":["//? Generated `apiMethodMap` registry. Replaces the prefix-heuristic\r\n//? in `isGetMethod` (apiRequest.ts) so abort-controller selection uses\r\n//? the actual HTTP method declared on the handler instead of guessing\r\n//? from the name prefix.\r\n//?\r\n//? Wiring: the project's `src/_sockets/socketInitializer.ts` (or any boot\r\n//? entry that imports `apiTypes.generated.ts`) calls\r\n//? `registerApiMethodMap(apiMethodMap)` once at module load. Until then,\r\n//? lookups return `undefined` and `isGetMethod` falls back to the\r\n//? prefix heuristic (preserving previous behavior for transitional code).\r\n\r\nexport type HttpMethodLiteral = 'GET' | 'POST' | 'PUT' | 'DELETE';\r\n\r\nexport type ApiMethodMap = Record<string, Record<string, Record<string, HttpMethodLiteral>>>;\r\n\r\nimport { createRegistry } from './createRegistry';\r\n\r\nconst registry = createRegistry<ApiMethodMap | null>(null);\r\n\r\nexport const registerApiMethodMap = (map: ApiMethodMap): void => {\r\n registry.register(map);\r\n};\r\n\r\nexport const getRegisteredApiMethod = (\r\n pagePath: string,\r\n apiName: string,\r\n version: string,\r\n): HttpMethodLiteral | undefined => {\r\n const activeMap = registry.get();\r\n if (!activeMap) return undefined;\r\n //? The map shape is `Record<page, Record<name, Record<version, method>>>`;\r\n //? TS treats each indexed access as defined (no `noUncheckedIndexedAccess`),\r\n //? so the optional chains look redundant. They're not — a missing key returns\r\n //? undefined at runtime, which is the contract this function is documenting.\r\n /* eslint-disable @typescript-eslint/no-unnecessary-condition -- runtime-defensive index access */\r\n return activeMap[pagePath]?.[apiName]?.[version];\r\n /* eslint-enable @typescript-eslint/no-unnecessary-condition */\r\n};\r\n\r\nexport const isApiMethodMapRegistered = (): boolean => registry.isRegistered();\r\n","/**\r\n * HTTP API Utilities\r\n * \r\n * Helpers for HTTP API request handling including:\r\n * - Smart HTTP method detection based on API name\r\n * - Method validation\r\n */\r\n\r\nexport type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';\r\n\r\n/**\r\n * Single source of truth for the GET-prefix naming heuristic\r\n * (`get` / `fetch` / `list`). Used by both `inferHttpMethod` here and the\r\n * client transport's `isGetMethod` fallback in `apiRequest.ts`, so the two\r\n * prefix heuristics can't drift apart. Expects an already-lowercased leaf name.\r\n */\r\nexport const isGetMethodName = (methodNameLower: string): boolean =>\r\n methodNameLower.startsWith('get') ||\r\n methodNameLower.startsWith('fetch') ||\r\n methodNameLower.startsWith('list');\r\n\r\n/**\r\n * Infer the HTTP method from an API name based on common naming conventions.\r\n * \r\n * - Names starting with \"get\" → GET\r\n * - Names starting with \"delete\" or \"remove\" → DELETE\r\n * - Names starting with \"update\" or \"edit\" → PUT\r\n * - Everything else → POST (create operations, actions)\r\n * \r\n * @example\r\n * ```typescript\r\n * inferHttpMethod('getUserData') // → 'GET'\r\n * inferHttpMethod('deleteAccount') // → 'DELETE'\r\n * inferHttpMethod('updateProfile') // → 'PUT'\r\n * inferHttpMethod('createUser') // → 'POST'\r\n * inferHttpMethod('sendEmail') // → 'POST'\r\n * ```\r\n */\r\nexport const inferHttpMethod = (apiName: string): HttpMethod => {\r\n // Extract the method name from the full path (e.g., \"api/examples/getUserData\" → \"getUserData\")\r\n const nameParts = apiName.split('/');\r\n const lastPart = nameParts.at(-1)?.toLowerCase() ?? '';\r\n const methodName = /^v\\d+$/.test(lastPart)\r\n ? (nameParts.at(-2) ?? '').toLowerCase()\r\n : lastPart;\r\n\r\n if (isGetMethodName(methodName)) {\r\n return 'GET';\r\n }\r\n\r\n if (methodName.startsWith('delete') || methodName.startsWith('remove')) {\r\n return 'DELETE';\r\n }\r\n\r\n if (methodName.startsWith('update') || methodName.startsWith('edit') || methodName.startsWith('patch')) {\r\n return 'PUT';\r\n }\r\n\r\n // Default: POST for create operations and general actions\r\n return 'POST';\r\n};\r\n\r\n/**\r\n * Get the effective HTTP method for an API.\r\n * Uses explicit export if available, otherwise infers from name.\r\n */\r\nexport const getEffectiveHttpMethod = (\r\n apiConfig: { httpMethod?: HttpMethod },\r\n apiName: string\r\n): HttpMethod => {\r\n return apiConfig.httpMethod ?? inferHttpMethod(apiName);\r\n};\r\n\r\n/**\r\n * Check if an HTTP method is valid for an API.\r\n *\r\n * @param requestMethod - The method from the HTTP request\r\n * @param allowedMethod - The method the API accepts\r\n * @returns true only when the method matches the route's allowed method.\r\n *\r\n * NOTE: `OPTIONS` is intentionally NOT treated as allowed here. A custom-route\r\n * author wiring this into their dispatch must answer CORS preflights BEFORE the\r\n * route check — letting `OPTIONS` through would execute the handler on a request\r\n * the CSRF middleware treats as non-state-changing (CSRF-exempt), opening a\r\n * bypass on method-locked routes.\r\n */\r\nexport const isMethodAllowed = (\r\n requestMethod: string,\r\n allowedMethod: HttpMethod\r\n): boolean => {\r\n return requestMethod === allowedMethod;\r\n};\r\n","import type { Socket } from 'socket.io-client';\r\n\r\n// Shared, mutable socket-client state. Single source of truth for the socket\r\n// instance, consumed by transport helpers in @luckystack/core (apiRequest)\r\n// and @luckystack/sync (syncRequest), and written by the project's React\r\n// hook wiring in `src/_sockets/socketInitializer.ts` via `setSocket(io(...))`.\r\n//\r\n// The `export let socket` form gives importers a live binding — they always\r\n// see the current value after `setSocket` replaces it.\r\n\r\n// eslint-disable-next-line import-x/no-mutable-exports\r\nexport let socket: Socket | null = null;\r\n\r\n//? One-shot waiters: promises that resolve the moment a non-null socket is\r\n//? set. Replacing the original `setInterval` poll (10 ms × 500 iterations)\r\n//? with a subscribe-and-notify pattern eliminates the busy-loop overhead and\r\n//? guarantees sub-tick delivery rather than up-to-10 ms latency.\r\nlet waiters: ((s: Socket) => void)[] = [];\r\n\r\n//? Absolute deadline for `waitForSocket` — prevents a permanently-dangling\r\n//? promise in environments where the socket is never created (SSR, tests).\r\nconst WAIT_FOR_SOCKET_TIMEOUT_MS = 5000;\r\n\r\nexport const setSocket = (next: Socket | null): void => {\r\n socket = next;\r\n //? Resolve all pending waiters the moment a real socket arrives.\r\n if (next) {\r\n const pending = waiters;\r\n waiters = [];\r\n for (const resolve of pending) {\r\n resolve(next);\r\n }\r\n }\r\n};\r\n\r\nlet responseIndex = 0;\r\n\r\nexport const incrementResponseIndex = (): number => {\r\n responseIndex = responseIndex + 1;\r\n return responseIndex;\r\n};\r\n\r\nexport const waitForSocket = (): Promise<Socket | null> => {\r\n if (socket) return Promise.resolve(socket);\r\n return new Promise<Socket | null>((resolve) => {\r\n const timer = setTimeout(() => {\r\n //? Time out: remove our waiter slot and resolve with null so the\r\n //? caller surfaces a \"socket unavailable\" path instead of hanging.\r\n waiters = waiters.filter((w) => w !== onSocket);\r\n resolve(null);\r\n }, WAIT_FOR_SOCKET_TIMEOUT_MS);\r\n\r\n const onSocket = (s: Socket): void => {\r\n clearTimeout(timer);\r\n resolve(s);\r\n };\r\n\r\n waiters.push(onSocket);\r\n });\r\n};\r\n","//? Client-side notifier DI. Framework packages (apiRequest, syncRequest,\r\n//? offline queue flush) emit toasts on transport errors. Default is silent\r\n//? (no-op) so tests and SSR contexts don't need a toaster installed.\r\n//?\r\n//? The project's entry point registers a notifier backed by its UI toolkit\r\n//? (sonner + i18n) via `registerNotifier({ error, success, ... })`.\r\n\r\nimport { createRegistry } from './createRegistry';\r\n\r\nexport interface NotifyParam {\r\n key: string;\r\n value: string | number | boolean;\r\n}\r\n\r\nexport interface NotifyInput {\r\n key: string;\r\n params?: NotifyParam[];\r\n}\r\n\r\nexport interface Notifier {\r\n success: (input: NotifyInput) => void;\r\n error: (input: NotifyInput) => void;\r\n info: (input: NotifyInput) => void;\r\n warning: (input: NotifyInput) => void;\r\n}\r\n\r\nconst noopNotifier: Notifier = {\r\n success: () => { /* no-op */ },\r\n error: () => { /* no-op */ },\r\n info: () => { /* no-op */ },\r\n warning: () => { /* no-op */ },\r\n};\r\n\r\nconst registry = createRegistry<Notifier>(noopNotifier);\r\n\r\nexport const registerNotifier = (notifier: Notifier): void => {\r\n registry.register(notifier);\r\n};\r\n\r\nexport const getNotifier = (): Notifier => registry.get();\r\n\r\n//? Delegating wrapper matching the project's existing `notify` shape so the\r\n//? switchover inside framework packages is a one-line import change.\r\nexport const notify: Notifier = {\r\n success: (input) => { registry.get().success(input); },\r\n error: (input) => { registry.get().error(input); },\r\n info: (input) => { registry.get().info(input); },\r\n warning: (input) => { registry.get().warning(input); },\r\n};\r\n","//? Synchronous sibling of `tryCatch`. The async `tryCatch` returns a Promise\r\n//? and auto-captures to the error tracker — neither of which fits a hot,\r\n//? genuinely-synchronous path (error-formatter dispatch, cookie/CSRF parse)\r\n//? that must stay synchronous and decides its own logging.\r\n//?\r\n//? Unlike the async `tryCatch`, this helper does NOT auto-capture to the\r\n//? error tracker: the call sites that need it are synchronous fallbacks that\r\n//? already do their own contextual logging and deliberately keep the error\r\n//? path side-effect-light. Capture explicitly on the error tuple when needed.\r\n\r\n//? `P = void` mirrors the async `tryCatch<T, P = void>` so a params-less call\r\n//? can pass just the result type: `tryCatchSync<URL>(() => new URL(raw))`.\r\n//? Without the default TS demands both type args (TS2558).\r\nexport default function tryCatchSync<T, P = void>(\r\n func: (values: P) => T,\r\n params?: P,\r\n): [Error | null, T | null] {\r\n try {\r\n const response = func(params as P);\r\n return [null, response];\r\n } catch (error) {\r\n return [error instanceof Error ? error : new Error(String(error)), null];\r\n }\r\n}\r\n","//? Client-side hook bus. Counterpart to the server-side `registerHook` /\r\n//? `dispatchHook` system in `hookRegistry.ts`, but lives here in the\r\n//? browser-safe surface and listens for client-side lifecycle moments:\r\n//? session login, session logout (and room for `sessionUpdate` later).\r\n//?\r\n//? Fired from `react/sessionContext.ts` when `setLatestSession` observes\r\n//? a null → non-null (postLogin) or non-null → null (postLogout) transition.\r\n//? That makes the fire-point framework-controlled — consumers don't have to\r\n//? add their own useEffect in `SessionProvider` to detect these moments.\r\n//?\r\n//? Handlers are best-effort: errors are caught + logged but don't break the\r\n//? session pipeline. Async handlers fire-and-forget; the caller of\r\n//? `setLatestSession` does not await.\r\n//?\r\n//? Typical use:\r\n//? import { registerClientHook } from '@luckystack/core/client';\r\n//?\r\n//? registerClientHook('postLogin', ({ session }) => {\r\n//? const letter = session.name?.[0]?.toLowerCase();\r\n//? if (letter) joinRoom(`letter-${letter}`);\r\n//? });\r\n//?\r\n//? Consumers with extended session shapes get narrow typing via the same\r\n//? `BaseSessionLayout` module augmentation pattern the rest of the framework\r\n//? uses (see ARCHITECTURE_EXTENSION_POINTS.md `@luckystack/login` section).\r\n\r\nimport type { BaseSessionLayout } from './sessionTypes';\r\nimport { getLogger } from './loggerRegistry';\r\n\r\nexport interface ClientHookPayloadMap {\r\n //? Fires BEFORE the framework commits a null → session transition.\r\n //? Handlers may return a `ClientHookStopSignal` to veto the login (e.g.\r\n //? account suspended since last visit, feature-flag gate, geo block).\r\n //? Only fires when the consumer commits the transition through the\r\n //? vetoable entry point — see `proposeLogin` in `react/sessionContext.ts`.\r\n preLogin: { candidateSession: BaseSessionLayout };\r\n postLogin: { session: BaseSessionLayout };\r\n postLogout: { previousSession: BaseSessionLayout };\r\n //? Fires when the offline queue drops a request (full queue with a\r\n //? `drop-oldest`/`drop-newest`/`reject` policy, or an item past `maxAgeMs`).\r\n //? The in-memory queue is not durable across refreshes; this is the seam to\r\n //? surface a \"your change wasn't sent\" toast or to persist the item\r\n //? elsewhere. `queue` says which queue, `reason` why it was dropped.\r\n queueItemDropped: {\r\n queue: 'api' | 'sync';\r\n key: string;\r\n reason: 'queue-full' | 'expired';\r\n dropPolicy: 'drop-oldest' | 'drop-newest' | 'reject';\r\n };\r\n}\r\n\r\nexport type ClientHookName = keyof ClientHookPayloadMap;\r\n\r\n//? Stop signal returned by a `pre*` client hook handler to veto the\r\n//? framework's main flow. Mirrors the server-side `HookStopSignal` shape so\r\n//? `errorCode` flows through the same i18n / responseNormalizer pipeline a\r\n//? consumer already wires up.\r\nexport interface ClientHookStopSignal {\r\n stop: true;\r\n errorCode: string;\r\n}\r\n\r\n// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- mirrors server-side HookResult, see hooks/types.ts\r\nexport type ClientHookResult = void | undefined | ClientHookStopSignal;\r\n\r\nexport type ClientDispatchResult =\r\n | { stopped: false }\r\n | { stopped: true; signal: ClientHookStopSignal };\r\n\r\nexport type ClientHookHandler<N extends ClientHookName> = (\r\n payload: ClientHookPayloadMap[N],\r\n) => ClientHookResult | Promise<ClientHookResult>;\r\n\r\n//? Storage uses `unknown` handler sets so the generic `N` doesn't have to\r\n//? narrow at every access — the cast happens once at read/write inside the\r\n//? typed wrappers below, which is sound because each hook name's Set only\r\n//? ever sees handlers registered under that same name.\r\ntype AnyHandler = (payload: unknown) => ClientHookResult | Promise<ClientHookResult>;\r\nconst handlerSets: Partial<Record<ClientHookName, Set<AnyHandler>>> = {};\r\n\r\n/**\r\n * Subscribe to a client-side lifecycle hook. Returns an unsubscribe function\r\n * — call it to remove the handler (e.g. inside a React effect cleanup).\r\n *\r\n * Multiple handlers can be registered for the same hook; they all fire on\r\n * each dispatch. A handler that throws (sync) or rejects (async) is logged\r\n * to `console.error` and does not affect other handlers.\r\n */\r\nexport function registerClientHook<N extends ClientHookName>(\r\n name: N,\r\n handler: ClientHookHandler<N>,\r\n): () => void {\r\n const existing = handlerSets[name];\r\n const set = existing ?? new Set<AnyHandler>();\r\n if (!existing) handlerSets[name] = set;\r\n const wrapped = handler as AnyHandler;\r\n set.add(wrapped);\r\n return () => {\r\n set.delete(wrapped);\r\n };\r\n}\r\n\r\n/**\r\n * Fire a client-side hook (fire-and-forget). Framework-internal — consumers\r\n * should not call this directly. Used for `post*` hooks where any stop-signal\r\n * returned by a handler is intentionally ignored: a `postLogin` listener that\r\n * tried to \"stop\" the framework would be too late — the transition already\r\n * happened. Use `dispatchVetoableClientHook` for `pre*` hooks where the\r\n * caller actually awaits the veto decision.\r\n */\r\nexport function dispatchClientHook<N extends ClientHookName>(\r\n name: N,\r\n payload: ClientHookPayloadMap[N],\r\n): void {\r\n const set = handlerSets[name];\r\n if (!set || set.size === 0) return;\r\n //? Snapshot the set before iterating so a handler that unregisters itself\r\n //? (or a sibling) mid-dispatch doesn't skip un-visited handlers — Set\r\n //? iteration honors mid-iter deletes per ECMAScript spec, which we don't\r\n //? want here.\r\n const handlers = [...set];\r\n for (const handler of handlers) {\r\n try {\r\n const result = handler(payload);\r\n if (result && typeof (result as Promise<unknown>).catch === 'function') {\r\n (result as Promise<unknown>).catch((error: unknown) => {\r\n getLogger().error(`[clientHook:${name}] async handler rejected`, error);\r\n });\r\n }\r\n } catch (error) {\r\n getLogger().error(`[clientHook:${name}] handler threw`, error);\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Awaits every registered handler for a `pre*` client hook. If any handler\r\n * returns a `ClientHookStopSignal`, dispatch short-circuits and returns\r\n * `{ stopped: true, signal }`; callers must respect the veto (skip the\r\n * pending action). Handlers that throw or reject are logged but treated as\r\n * `undefined` — a buggy handler must not silently block the user flow.\r\n *\r\n * Mirrors `dispatchHook` from `@luckystack/core` so consumer/framework code\r\n * can use the same stop-signal pattern on both sides of the wire.\r\n */\r\nexport async function dispatchVetoableClientHook<N extends ClientHookName>(\r\n name: N,\r\n payload: ClientHookPayloadMap[N],\r\n): Promise<ClientDispatchResult> {\r\n const set = handlerSets[name];\r\n if (!set || set.size === 0) return { stopped: false };\r\n //? Snapshot the set (see `dispatchClientHook` for why).\r\n const handlers = [...set];\r\n for (const handler of handlers) {\r\n let result: ClientHookResult;\r\n try {\r\n result = await handler(payload);\r\n } catch (error) {\r\n getLogger().error(`[clientHook:${name}] vetoable handler threw`, error);\r\n continue;\r\n }\r\n if (result?.stop) {\r\n return { stopped: true, signal: result };\r\n }\r\n }\r\n return { stopped: false };\r\n}\r\n\r\n/** Test helper — drop every registered handler. Not part of the public API. */\r\nexport function _resetClientHooksForTests(): void {\r\n for (const key of Object.keys(handlerSets) as ClientHookName[]) {\r\n handlerSets[key]?.clear();\r\n }\r\n}\r\n","import { Socket } from \"socket.io-client\";\r\nimport { getProjectConfig } from \"./projectConfig\";\r\nimport { getLogger } from \"./loggerRegistry\";\r\nimport tryCatchSync from \"./tryCatchSync\";\r\nimport { dispatchClientHook } from \"./clientHookBus\";\r\n\r\n/** Why a queued item was dropped (handed to its `onDrop` callback). */\r\nexport type QueueDropReason = 'expired' | 'queue-full';\r\n\r\ninterface ApiQueueItem {\r\n id: string;\r\n key: string;\r\n run: (socketInstance: Socket) => void;\r\n createdAt: number;\r\n /**\r\n * Per-request override of `projectConfig.offlineQueue.dropPolicy`. See\r\n * `SyncQueueItem.dropPolicy` for details.\r\n */\r\n dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';\r\n /**\r\n * Optional callback invoked when THIS item is dropped (evicted for age or\r\n * because the queue was full / rejected) instead of ever running (SYNC-09).\r\n * The transport wires this to settle the pending `syncRequest`/`apiRequest`\r\n * promise with an error envelope, so an evicted offline request resolves\r\n * rather than hanging forever. Invoked at most once; failures are swallowed.\r\n */\r\n onDrop?: (reason: QueueDropReason) => void;\r\n}\r\n\r\ninterface SyncQueueItem {\r\n id: string;\r\n key: string;\r\n run: (socketInstance: Socket) => void;\r\n createdAt: number;\r\n /**\r\n * Per-request override of `projectConfig.offlineQueue.dropPolicy`. Lets a\r\n * specific sync (\"editor cursor move\") pick `'drop-oldest'` while the app\r\n * default stays `'reject'` for safer sends. When omitted, falls back to\r\n * the global config.\r\n */\r\n dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';\r\n /** See {@link ApiQueueItem.onDrop} (SYNC-09). */\r\n onDrop?: (reason: QueueDropReason) => void;\r\n}\r\n\r\nconst apiQueue: ApiQueueItem[] = [];\r\nconst syncQueue: SyncQueueItem[] = [];\r\n\r\n//? Independent flush guards so a long api flush doesn't no-op a concurrent\r\n//? sync flush (or vice versa).\r\nlet isFlushingApi = false;\r\nlet isFlushingSync = false;\r\n\r\nexport const isOnline = () => {\r\n if (typeof navigator === \"undefined\") return true;\r\n return navigator.onLine;\r\n};\r\n\r\n//? Common drop shape — every queue item the framework drops carries these.\r\ninterface DroppableItem {\r\n createdAt: number;\r\n key: string;\r\n dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';\r\n onDrop?: (reason: QueueDropReason) => void;\r\n}\r\n\r\n//? Notify a dropped item's owner exactly once. Swallows callback errors so a\r\n//? throwing `onDrop` can't wedge the eviction/enqueue loop (SYNC-09).\r\nconst notifyDropped = (item: DroppableItem, reason: QueueDropReason): void => {\r\n if (!item.onDrop) return;\r\n tryCatchSync(() => { item.onDrop?.(reason); });\r\n};\r\n\r\n//? Dispatch the `queueItemDropped` client hook, guarded so a misbehaving\r\n//? handler set can't interrupt the eviction/enqueue loop (CORE-N8).\r\nconst safeDispatchDropHook = (\r\n queue: 'api' | 'sync',\r\n key: string,\r\n reason: QueueDropReason,\r\n dropPolicy: 'drop-oldest' | 'drop-newest' | 'reject',\r\n): void => {\r\n tryCatchSync(() => {\r\n dispatchClientHook('queueItemDropped', { queue, key, reason, dropPolicy });\r\n });\r\n};\r\n\r\nconst evictExpired = (\r\n queue: DroppableItem[],\r\n label: 'api' | 'sync',\r\n): void => {\r\n const { maxAgeMs, dropPolicy: globalPolicy } = getProjectConfig().offlineQueue;\r\n if (maxAgeMs <= 0) return;\r\n const cutoff = Date.now() - maxAgeMs;\r\n for (let i = queue.length - 1; i >= 0; i -= 1) {\r\n const item = queue[i];\r\n if (item && item.createdAt < cutoff) {\r\n queue.splice(i, 1);\r\n notifyDropped(item, 'expired');\r\n safeDispatchDropHook(label, item.key, 'expired', item.dropPolicy ?? globalPolicy);\r\n }\r\n }\r\n};\r\n\r\nconst enqueueWithPolicy = <T extends DroppableItem>(\r\n queue: T[],\r\n item: T,\r\n label: 'api' | 'sync',\r\n): boolean => {\r\n evictExpired(queue, label);\r\n const { maxSize, dropPolicy: globalPolicy } = getProjectConfig().offlineQueue;\r\n //? Per-request override wins over the global policy. Lets specific\r\n //? syncs pick 'drop-oldest' for liveness while the app default stays\r\n //? 'reject' for safer sends.\r\n const effectivePolicy = item.dropPolicy ?? globalPolicy;\r\n if (queue.length < maxSize) {\r\n queue.push(item);\r\n return true;\r\n }\r\n if (effectivePolicy === 'drop-oldest') {\r\n const dropped = queue.shift();\r\n queue.push(item);\r\n getLogger().debug(`offlineQueue:${label} full — dropped oldest item (policy=${effectivePolicy})`);\r\n if (dropped) {\r\n notifyDropped(dropped, 'queue-full');\r\n safeDispatchDropHook(label, dropped.key, 'queue-full', effectivePolicy);\r\n }\r\n return true;\r\n }\r\n if (effectivePolicy === 'drop-newest') {\r\n getLogger().debug(`offlineQueue:${label} full — dropped newest item (policy=${effectivePolicy})`);\r\n notifyDropped(item, 'queue-full');\r\n safeDispatchDropHook(label, item.key, 'queue-full', effectivePolicy);\r\n return false;\r\n }\r\n getLogger().warn(`offlineQueue:${label} full — rejecting enqueue (policy=${effectivePolicy})`);\r\n notifyDropped(item, 'queue-full');\r\n safeDispatchDropHook(label, item.key, 'queue-full', effectivePolicy);\r\n return false;\r\n};\r\n\r\nexport const enqueueApiRequest = (item: ApiQueueItem): boolean =>\r\n enqueueWithPolicy(apiQueue, item, 'api');\r\n\r\nexport const enqueueSyncRequest = (item: SyncQueueItem): boolean =>\r\n enqueueWithPolicy(syncQueue, item, 'sync');\r\n\r\nexport const removeApiQueueItem = (id: string) => {\r\n const index = apiQueue.findIndex((item) => item.id === id);\r\n if (index !== -1) {\r\n apiQueue.splice(index, 1);\r\n }\r\n};\r\n\r\nexport const removeSyncQueueItem = (id: string) => {\r\n const index = syncQueue.findIndex((item) => item.id === id);\r\n if (index !== -1) {\r\n syncQueue.splice(index, 1);\r\n }\r\n};\r\n\r\nexport const removeApiQueueItemsByKey = (key: string) => {\r\n for (let i = apiQueue.length - 1; i >= 0; i -= 1) {\r\n if (apiQueue[i]?.key === key) {\r\n apiQueue.splice(i, 1);\r\n }\r\n }\r\n};\r\n\r\n//? Run one dequeued item. If `run` throws, requeue it at the FRONT (so the\r\n//? next flush retries it in order) and signal the caller to stop this flush —\r\n//? otherwise a throwing item is silently lost AND the `isFlushing*` guard\r\n//? would be left stuck `true`, wedging every future flush.\r\nconst runQueueItem = <T extends { run: (s: Socket) => void }>(\r\n queue: T[],\r\n item: T,\r\n socketInstance: Socket,\r\n label: string,\r\n): boolean => {\r\n const [error] = tryCatchSync(() => { item.run(socketInstance); });\r\n if (error) {\r\n queue.unshift(item);\r\n getLogger().warn(`offlineQueue:${label} run threw — requeued item, pausing flush`, { error });\r\n return false;\r\n }\r\n return true;\r\n};\r\n\r\nexport const flushApiQueue = (canRun: () => boolean, socketInstance: Socket) => {\r\n if (isFlushingApi) return;\r\n evictExpired(apiQueue, 'api');\r\n if (apiQueue.length === 0) return;\r\n if (!canRun()) return;\r\n\r\n isFlushingApi = true;\r\n while (apiQueue.length > 0) {\r\n if (!canRun()) break;\r\n const item = apiQueue.shift();\r\n if (item && !runQueueItem(apiQueue, item, socketInstance, 'api')) break;\r\n }\r\n isFlushingApi = false;\r\n};\r\n\r\nexport const flushSyncQueue = (canRun: () => boolean, socketInstance: Socket) => {\r\n if (isFlushingSync) return;\r\n evictExpired(syncQueue, 'sync');\r\n if (syncQueue.length === 0) return;\r\n if (!canRun()) return;\r\n\r\n isFlushingSync = true;\r\n while (syncQueue.length > 0) {\r\n if (!canRun()) break;\r\n const item = syncQueue.shift();\r\n if (item && !runQueueItem(syncQueue, item, socketInstance, 'sync')) break;\r\n }\r\n isFlushingSync = false;\r\n};\r\n\r\nexport const getApiQueueSize = () => apiQueue.length;\r\nexport const getSyncQueueSize = () => syncQueue.length;\r\n","export interface ErrorParam {\r\n key: string;\r\n value: string | number | boolean;\r\n}\r\n\r\nexport interface ErrorResponseInput {\r\n status?: unknown;\r\n errorCode?: unknown;\r\n errorParams?: unknown;\r\n httpStatus?: unknown;\r\n}\r\n\r\nexport interface NormalizedErrorResponse {\r\n status: 'error';\r\n message: string;\r\n errorCode: string;\r\n errorParams?: ErrorParam[];\r\n httpStatus?: number;\r\n}\r\n\r\nexport const INVALID_ERROR_RESPONSE_CODE = 'error.invalidResponse';\r\n\r\nexport const isErrorParamArray = (value: unknown): value is ErrorParam[] => {\r\n return Array.isArray(value) && value.every((item) => {\r\n return (\r\n !!item\r\n && typeof item === 'object'\r\n && typeof (item as ErrorParam).key === 'string'\r\n && ['string', 'number', 'boolean'].includes(typeof (item as ErrorParam).value)\r\n );\r\n });\r\n};\r\n\r\nexport const normalizeErrorResponseCore = ({\r\n response,\r\n fallbackHttpStatus,\r\n fallbackErrorCode,\r\n resolveMessage,\r\n}: {\r\n response: ErrorResponseInput;\r\n fallbackHttpStatus?: number;\r\n fallbackErrorCode?: string;\r\n resolveMessage?: (args: { errorCode: string; errorParams?: ErrorParam[] }) => string;\r\n}): NormalizedErrorResponse => {\r\n const normalizedParams = isErrorParamArray(response.errorParams) ? response.errorParams : undefined;\r\n const errorCodeValue = typeof response.errorCode === 'string' ? response.errorCode.trim() : '';\r\n const hasErrorCode = errorCodeValue.length > 0;\r\n const finalErrorCode = hasErrorCode ? errorCodeValue : (fallbackErrorCode ?? INVALID_ERROR_RESPONSE_CODE);\r\n const providedHttpStatus = typeof response.httpStatus === 'number' ? response.httpStatus : undefined;\r\n\r\n const message = resolveMessage\r\n ? resolveMessage({ errorCode: finalErrorCode, errorParams: normalizedParams })\r\n : finalErrorCode;\r\n\r\n return {\r\n status: 'error',\r\n message,\r\n errorCode: finalErrorCode,\r\n errorParams: normalizedParams,\r\n httpStatus: providedHttpStatus ?? fallbackHttpStatus,\r\n };\r\n};\r\n\r\nexport const defaultHttpStatusForResponse = ({\r\n status,\r\n explicitHttpStatus,\r\n fallbackErrorStatus = 400,\r\n}: {\r\n status: 'success' | 'error';\r\n explicitHttpStatus?: number;\r\n fallbackErrorStatus?: number;\r\n}): number => {\r\n if (typeof explicitHttpStatus === 'number') return explicitHttpStatus;\r\n return status === 'success' ? 200 : fallbackErrorStatus;\r\n};\r\n","const VERSION_SEGMENT_REGEX = /^v\\d+$/;\r\n\r\nexport interface ServiceRouteParseSuccess {\r\n status: 'success';\r\n normalizedRouteName: string;\r\n service: string;\r\n routeName: string;\r\n}\r\n\r\nexport interface ServiceRouteParseError {\r\n status: 'error';\r\n reason: string;\r\n}\r\n\r\nexport type ServiceRouteParseResult = ServiceRouteParseSuccess | ServiceRouteParseError;\r\n\r\nexport interface TransportRouteParseSuccess {\r\n status: 'success';\r\n normalizedFullName: string;\r\n version: string;\r\n serviceRoute: ServiceRouteParseSuccess;\r\n}\r\n\r\nexport interface TransportRouteParseError {\r\n status: 'error';\r\n reason: string;\r\n}\r\n\r\nexport type TransportRouteParseResult = TransportRouteParseSuccess | TransportRouteParseError;\r\n\r\nconst normalizeRawRoute = (value: string): string => {\r\n return value.trim().replaceAll('\\\\', '/').replaceAll(/^\\/+/g, '').replaceAll(/\\/+$/g, '');\r\n};\r\n\r\nconst hasEmptySegments = (value: string): boolean => {\r\n return value.split('/').some((segment) => segment.length === 0);\r\n};\r\n\r\nexport const parseServiceRouteName = (value: string): ServiceRouteParseResult => {\r\n const normalized = normalizeRawRoute(value);\r\n if (!normalized) {\r\n return { status: 'error', reason: 'Route name cannot be empty.' };\r\n }\r\n\r\n if (hasEmptySegments(normalized)) {\r\n return { status: 'error', reason: 'Route name cannot contain empty segments.' };\r\n }\r\n\r\n const segments = normalized.split('/');\r\n if (segments.length < 2) {\r\n return { status: 'error', reason: 'Route name must include service and route segments.' };\r\n }\r\n\r\n const [service, ...routeSegments] = segments;\r\n const routeName = routeSegments.join('/');\r\n\r\n if (!service || !routeName) {\r\n return { status: 'error', reason: 'Route name must include service and route segments.' };\r\n }\r\n\r\n return {\r\n status: 'success',\r\n normalizedRouteName: `${service}/${routeName}`,\r\n service,\r\n routeName,\r\n };\r\n};\r\n\r\nexport const parseTransportRouteName = ({\r\n value,\r\n prefix,\r\n}: {\r\n value: string;\r\n prefix: 'api' | 'sync';\r\n}): TransportRouteParseResult => {\r\n const normalized = normalizeRawRoute(value);\r\n if (!normalized) {\r\n return { status: 'error', reason: 'Transport route cannot be empty.' };\r\n }\r\n\r\n if (hasEmptySegments(normalized)) {\r\n return { status: 'error', reason: 'Transport route cannot contain empty segments.' };\r\n }\r\n\r\n const segments = normalized.split('/');\r\n const routeSegments = segments[0] === prefix ? segments.slice(1) : segments;\r\n\r\n if (routeSegments.length < 3) {\r\n return {\r\n status: 'error',\r\n reason: `Transport route must use ${prefix}/{service}/{name}/{version} format.`,\r\n };\r\n }\r\n\r\n const version = routeSegments.at(-1);\r\n if (!version || !VERSION_SEGMENT_REGEX.test(version)) {\r\n return { status: 'error', reason: 'Route version segment must match v{number}.' };\r\n }\r\n\r\n const serviceRouteToken = routeSegments.slice(0, -1).join('/');\r\n const serviceRoute = parseServiceRouteName(serviceRouteToken);\r\n if (serviceRoute.status === 'error') {\r\n return {\r\n status: 'error',\r\n reason: serviceRoute.reason,\r\n };\r\n }\r\n\r\n return {\r\n status: 'success',\r\n normalizedFullName: `${prefix}/${serviceRoute.normalizedRouteName}/${version}`,\r\n version,\r\n serviceRoute,\r\n };\r\n};\r\n","const withIndex = (prefix: string, responseIndex: number | string): string => {\r\n return `${prefix}${String(responseIndex)}`;\r\n};\r\n\r\nexport const socketEventNames = {\r\n apiRequest: 'apiRequest',\r\n apiCancel: 'apiCancel',\r\n sync: 'sync',\r\n syncCancel: 'syncCancel',\r\n joinRoom: 'joinRoom',\r\n leaveRoom: 'leaveRoom',\r\n getJoinedRooms: 'getJoinedRooms',\r\n updateLocation: 'updateLocation',\r\n updateSession: 'updateSession',\r\n sessionReplaced: 'sessionReplaced',\r\n logout: 'logout',\r\n\r\n intentionalDisconnect: 'intentionalDisconnect',\r\n intentionalReconnect: 'intentionalReconnect',\r\n //? Client -> server activity heartbeat (mouse/keyboard/touch), throttled.\r\n //? Drives the server-side activity sampler that fires registered activity\r\n //? events (e.g. the built-in AFK detector).\r\n activity: 'activity',\r\n userAfk: 'userAfk',\r\n userBack: 'userBack',\r\n //? Emitted to a room's remaining members when a peer leaves — hard disconnect\r\n //? (socket close) or grace-expiry (presence MIS-003). Lets clients prune a\r\n //? departed peer from their roster without polling. The presence package owns\r\n //? the dispatch; core owns the wire-event name so client + server can't drift.\r\n userLeft: 'userLeft',\r\n\r\n connect: 'connect',\r\n disconnect: 'disconnect',\r\n reconnectAttempt: 'reconnect_attempt',\r\n connectError: 'connect_error',\r\n\r\n apiResponsePrefix: 'apiResponse-',\r\n apiStreamPrefix: 'apiStream-',\r\n syncResponsePrefix: 'sync-',\r\n syncProgressPrefix: 'sync-progress-',\r\n joinRoomResponsePrefix: 'joinRoom-',\r\n leaveRoomResponsePrefix: 'leaveRoom-',\r\n getJoinedRoomsResponsePrefix: 'getJoinedRooms-',\r\n} as const;\r\n\r\nexport const buildApiResponseEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.apiResponsePrefix, responseIndex);\r\n};\r\n\r\nexport const buildApiStreamEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.apiStreamPrefix, responseIndex);\r\n};\r\n\r\nexport const buildSyncResponseEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.syncResponsePrefix, responseIndex);\r\n};\r\n\r\nexport const buildSyncProgressEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.syncProgressPrefix, responseIndex);\r\n};\r\n\r\nexport const buildJoinRoomResponseEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.joinRoomResponsePrefix, responseIndex);\r\n};\r\n\r\nexport const buildLeaveRoomResponseEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.leaveRoomResponsePrefix, responseIndex);\r\n};\r\n\r\nexport const buildGetJoinedRoomsResponseEventName = (responseIndex: number | string): string => {\r\n return withIndex(socketEventNames.getJoinedRoomsResponsePrefix, responseIndex);\r\n};\r\n","export default function sleep(ms: number) {\r\n return new Promise<void>((resolve) => setTimeout(resolve, ms));\r\n}\r\n","//? CSRF configuration registry. The framework's CSRF middleware\r\n//? (`packages/server/src/httpRoutes/csrfMiddleware.ts`) and the\r\n//? client-side fetch wrapper (`packages/core/src/csrf.ts`) both consult\r\n//? `getCsrfConfig()` so consumers can change the header name, cookie\r\n//? name, token length, or cookie options without forking.\r\n//?\r\n//? The default values match the framework's pre-registry hardcodes\r\n//? (header `x-csrf-token`, 32-byte hex token minted at session create) so\r\n//? consumers who never call `registerCsrfConfig` get the historical\r\n//? behaviour unchanged.\r\n\r\nimport { createRegistry } from './createRegistry';\r\n\r\nexport interface CsrfCookieOptions {\r\n sameSite?: 'strict' | 'lax' | 'none';\r\n secure?: boolean;\r\n /**\r\n * MUST be false for CSRF cookies — the client needs to read the value\r\n * with JS to attach it as a request header. The framework reads the\r\n * value from the session record (Redis-side) rather than the cookie,\r\n * so this flag is documentation/intent only.\r\n */\r\n httpOnly?: boolean;\r\n path?: string;\r\n maxAgeMs?: number;\r\n}\r\n\r\nexport interface CsrfConfig {\r\n /** Cookie name used to deliver the CSRF token to the browser. */\r\n cookieName: string;\r\n /** Request-header name the client attaches on state-changing requests. */\r\n headerName: string;\r\n /** Length in bytes of the random token minted by the session layer. */\r\n tokenLength: number;\r\n cookieOptions: CsrfCookieOptions;\r\n}\r\n\r\nexport const DEFAULT_CSRF_CONFIG: CsrfConfig = {\r\n cookieName: 'csrf-token',\r\n headerName: 'x-csrf-token',\r\n tokenLength: 32,\r\n cookieOptions: {\r\n sameSite: 'lax',\r\n //? `secure` intentionally UNSET so it resolves per-environment at serialize\r\n //? time (csrfRoute.ts) the SAME way the session cookie does — emit `Secure` in\r\n //? prod (SECURE=true) but NOT over plain HTTP in dev. A hardcoded `true` made\r\n //? the browser drop the login-absent double-submit cookie over http://, so\r\n //? every state-changing request 403'd. Force it on explicitly via\r\n //? `registerCsrfConfig({ cookieOptions: { secure: true } })` if desired.\r\n httpOnly: false,\r\n path: '/',\r\n maxAgeMs: 24 * 60 * 60 * 1000,\r\n },\r\n};\r\n\r\nconst registry = createRegistry<CsrfConfig, Partial<CsrfConfig>>(DEFAULT_CSRF_CONFIG, {\r\n //? Merge shallowly with the CURRENT config; `cookieOptions` is deep-merged\r\n //? so a partial override of (say) `sameSite` does not clobber `path` /\r\n //? `maxAgeMs`. Last-write-wins, and successive calls accumulate.\r\n transform: (input, current) => ({\r\n ...current,\r\n ...input,\r\n cookieOptions: { ...current.cookieOptions, ...input.cookieOptions },\r\n }),\r\n});\r\n\r\n/**\r\n * Override one or more CSRF settings. Merges shallowly with the current\r\n * config; `cookieOptions` is deep-merged so a partial override of (say)\r\n * `sameSite` does not clobber `path` / `maxAgeMs`. Last-write-wins.\r\n */\r\nexport const registerCsrfConfig = (input: Partial<CsrfConfig>): void => {\r\n registry.register(input);\r\n};\r\n\r\n/** Read the active config at call time (never at module load). */\r\nexport const getCsrfConfig = (): CsrfConfig => registry.get();\r\n\r\n/** Test-only — restore defaults between scenarios. */\r\nexport const resetCsrfConfigForTests = (): void => {\r\n registry.reset();\r\n};\r\n","import { captureException } from './sentrySetup';\r\n\r\nexport default async function tryCatch<T, P = void>(\r\n func: (values: P) => Promise<T> | T,\r\n params?: P,\r\n context?: Record<string, unknown>\r\n): Promise<[Error | null, T | null]> {\r\n try {\r\n const response = await func(params as P);\r\n return [null, response];\r\n } catch (error) {\r\n captureException(error, context);\r\n return [error instanceof Error ? error : new Error(String(error)), null];\r\n }\r\n}\r\n","//? Client-side CSRF helper. Lazily fetches and caches the session's CSRF\r\n//? token from `/auth/csrf`, then attaches it as `x-csrf-token` on\r\n//? subsequent HTTP fetches in cookie mode.\r\n//?\r\n//? Token-mode sessions are CSRF-immune because cross-origin POSTs do not\r\n//? auto-attach the sessionStorage value; the cache simply stays empty in\r\n//? that mode.\r\n\r\nimport { getCsrfConfig } from './csrfConfig';\r\nimport { getProjectConfig } from './projectConfig';\r\nimport { socket } from './socketState';\r\nimport tryCatch from './tryCatch';\r\n\r\nlet cachedToken: string | null = null;\r\nlet inflightFetch: Promise<string | null> | null = null;\r\n\r\nconst resolveBackendUrl = (): string => {\r\n //? Prefer the live socket's connection URI — it IS the backend origin, even\r\n //? in the split-origin dev model (Vite on :5173, server on :80) where\r\n //? `location.origin` would point at the FRONTEND and `/auth/csrf` would hit\r\n //? the dev server's SPA fallback (HTML, not JSON → token resolution fails).\r\n const socketUri = socket?.io.opts.hostname\r\n ? `${socket.io.opts.secure ? 'https' : 'http'}://${socket.io.opts.hostname}${socket.io.opts.port ? `:${socket.io.opts.port}` : ''}`\r\n : '';\r\n if (socketUri) return socketUri;\r\n\r\n // Same-origin deploys (template prod + Vite proxy) fall back to the\r\n // browser's location. Server-side callers should not invoke this helper —\r\n // they have direct access to the session.\r\n //? Both checks here look redundant to TS (globals are always typed as set)\r\n //? but at runtime in Node `globalThis.window` is undefined; the defensive\r\n //? guard is what keeps SSR from crashing.\r\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- SSR runtime guard\r\n if (globalThis.window !== undefined && globalThis.location?.origin) {\r\n return globalThis.location.origin;\r\n }\r\n return '';\r\n};\r\n\r\nconst fetchCsrfToken = async (): Promise<string | null> => {\r\n const base = resolveBackendUrl();\r\n const [error, token] = await tryCatch<string | null, undefined>(async () => {\r\n const response = await fetch(`${base}/auth/csrf`, {\r\n method: 'GET',\r\n credentials: 'include',\r\n });\r\n if (!response.ok) return null;\r\n const body = (await response.json()) as { csrfToken?: string | null };\r\n return body.csrfToken ?? null;\r\n });\r\n if (error) return null;\r\n return token ?? null;\r\n};\r\n\r\n/**\r\n * Fetch (or return the cached) CSRF token for the active session. Returns\r\n * null in token mode or when no session exists. Callers should attach the\r\n * returned value as the `X-CSRF-Token` request header.\r\n */\r\nexport const getCsrfToken = async (): Promise<string | null> => {\r\n //? Token-mode sessions: skip entirely. The server doesn't enforce CSRF\r\n //? in token mode, and there's no value to attach.\r\n if (getProjectConfig().session.basedToken) {\r\n return null;\r\n }\r\n\r\n if (cachedToken) return cachedToken;\r\n if (inflightFetch) return inflightFetch;\r\n\r\n inflightFetch = fetchCsrfToken().then((token) => {\r\n cachedToken = token;\r\n inflightFetch = null;\r\n return token;\r\n });\r\n\r\n return inflightFetch;\r\n};\r\n\r\n/** Drop the cached token. Call this on logout or when a 403 csrfMismatch is seen. */\r\nexport const clearCsrfToken = (): void => {\r\n cachedToken = null;\r\n inflightFetch = null;\r\n};\r\n\r\nconst STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);\r\n\r\nconst isCsrfMismatchResponse = async (response: Response): Promise<boolean> => {\r\n if (response.status !== 403) return false;\r\n const [error, isMismatch] = await tryCatch<boolean, undefined>(async () => {\r\n //? `response.clone()` is used here so the ORIGINAL response body stream is\r\n //? preserved for the caller (the clone is consumed, the original is not).\r\n //? This is important: `httpFetch` returns the original `response` reference\r\n //? to the caller so it can read its body, and cloning before reading is the\r\n //? correct way to peek at the body without consuming it.\r\n const cloned = response.clone();\r\n const body = (await cloned.json()) as { errorCode?: string };\r\n return body.errorCode === 'auth.csrfMismatch';\r\n });\r\n if (error) return false;\r\n return isMismatch ?? false;\r\n};\r\n\r\n/**\r\n * Drop-in replacement for `fetch` that automatically attaches the framework's\r\n * `X-CSRF-Token` and `X-Request-Id` headers to state-changing requests in\r\n * cookie mode. Use this for any project-side HTTP call that hits a\r\n * LuckyStack `/api/*`, `/sync/*`, or `/auth/api/*` endpoint.\r\n *\r\n * Behavior:\r\n * - GET / OPTIONS / HEAD: passes through without CSRF (read-only).\r\n * - Token mode: passes through without CSRF (immune by design).\r\n * - Cookie mode + state-changing method: lazily fetches `/auth/csrf` once,\r\n * caches, attaches as `x-csrf-token`. On a 403 `auth.csrfMismatch`\r\n * response, clears the cache and retries once.\r\n * - `credentials: 'include'` is forced unless the caller overrode it.\r\n *\r\n * @example\r\n * import { httpFetch } from '@luckystack/core/client';\r\n * const res = await httpFetch('/api/system/myRoute/v1', {\r\n * method: 'POST',\r\n * body: JSON.stringify({ ... }),\r\n * });\r\n */\r\nexport const httpFetch: typeof fetch = async (input, init = {}) => {\r\n const method = (init.method ?? 'GET').toUpperCase();\r\n const headers = new Headers(init.headers);\r\n if (!headers.has('Content-Type') && init.body && typeof init.body === 'string') {\r\n headers.set('Content-Type', 'application/json');\r\n }\r\n\r\n const csrfHeaderName = getCsrfConfig().headerName;\r\n const send = async (csrfToken: string | null): Promise<Response> => {\r\n const finalHeaders = new Headers(headers);\r\n if (csrfToken && STATE_CHANGING.has(method) && !finalHeaders.has(csrfHeaderName)) {\r\n finalHeaders.set(csrfHeaderName, csrfToken);\r\n }\r\n return fetch(input, {\r\n ...init,\r\n method,\r\n headers: finalHeaders,\r\n credentials: init.credentials ?? 'include',\r\n });\r\n };\r\n\r\n if (!STATE_CHANGING.has(method) || getProjectConfig().session.basedToken) {\r\n return send(null);\r\n }\r\n\r\n const token = await getCsrfToken();\r\n let response = await send(token);\r\n\r\n //? On csrfMismatch, the cached token is stale (session rotated). Clear and\r\n //? retry ONCE with a fresh fetch — covers the case where the user logged\r\n //? back in while a tab was idle. The retry is intentionally limited to one\r\n //? attempt: a second csrfMismatch means the session is broken in a way a\r\n //? fresh token won't fix (e.g. session expired), and retrying would loop.\r\n if (await isCsrfMismatchResponse(response)) {\r\n clearCsrfToken();\r\n const refreshed = await getCsrfToken();\r\n if (refreshed) {\r\n response = await send(refreshed);\r\n }\r\n }\r\n\r\n return response;\r\n};\r\n","//? Pure page-route validator. Lives in @luckystack/core (no Node deps) so\r\n//? both the client-side router (`src/main.tsx`'s `getRoutes()`) AND the\r\n//? devkit-side scaffold CLI / hot-reload validator can consume it through\r\n//? the same single source of truth.\r\n//?\r\n//? Implements the invisible-parent folder convention: a folder starting\r\n//? with `privateFolderPrefix` is stripped from the URL but its CHILDREN\r\n//? remain routeable. A `page.tsx` placed directly inside such a folder\r\n//? (no non-private sibling segment) is invalid because it has no URL.\r\n//?\r\n//? Reserved framework folders (`_api`, `_sync`, `_components`, …) NEVER\r\n//? host a page, even nested — placing `page.tsx` anywhere under one of\r\n//? them is a hard error.\r\n\r\nexport interface PageRouteRules {\r\n /**\r\n * Single-character prefix that marks a folder as **invisible-parent** for\r\n * page routing. Default: `'_'`.\r\n */\r\n privateFolderPrefix: string;\r\n /**\r\n * Folder names that are reserved for framework-internal use and may NEVER\r\n * host a `page.tsx`, nested or otherwise. Scaffold + page-discovery emit\r\n * a hard error when a page is placed inside one.\r\n */\r\n scaffoldIgnoredFolders: readonly string[];\r\n}\r\n\r\nexport const DEFAULT_PAGE_ROUTE_RULES: PageRouteRules = {\r\n privateFolderPrefix: '_',\r\n scaffoldIgnoredFolders: [\r\n '_api',\r\n '_sync',\r\n '_function',\r\n '_functions',\r\n '_component',\r\n '_components',\r\n '_provider',\r\n '_providers',\r\n '_locale',\r\n '_locales',\r\n '_socket',\r\n '_sockets',\r\n '_shared',\r\n '_server',\r\n ],\r\n};\r\n\r\nexport interface PagePathValidationResult {\r\n /** `true` when the page file produces a routeable URL. */\r\n valid: boolean;\r\n /** Computed URL when `valid`. Always begins with `/`. `/` for root page. */\r\n route?: string;\r\n /** Human-readable explanation when `!valid`. */\r\n reason?: string;\r\n}\r\n\r\n/**\r\n * Validate a `page.tsx` file path relative to `src/`. Accepts both\r\n * forward and back slashes, with or without a leading `./` or `/`.\r\n * The filename **must** be `page.tsx` (or `page.jsx`); other filenames\r\n * return `valid: false`.\r\n *\r\n * Examples (defaults):\r\n * admin/page.tsx -> { valid: true, route: '/admin' }\r\n * _housing/renting/page.tsx -> { valid: true, route: '/renting' }\r\n * _housing/page.tsx -> { valid: false, reason: 'no URL segment left after stripping underscore folders' }\r\n * _api/something/page.tsx -> { valid: false, reason: 'page.tsx cannot live inside reserved folder _api' }\r\n * page.tsx -> { valid: true, route: '/' }\r\n * some/route.tsx -> { valid: false, reason: 'not a page file (expected page.tsx)' }\r\n */\r\nexport const validatePagePath = (\r\n srcRelativePath: string,\r\n rules: PageRouteRules = DEFAULT_PAGE_ROUTE_RULES,\r\n): PagePathValidationResult => {\r\n const normalized = srcRelativePath.replaceAll('\\\\', '/').replace(/^\\.\\//, '').replace(/^\\/+/, '');\r\n const segments = normalized.split('/').filter((segment) => segment.length > 0);\r\n\r\n if (segments.length === 0) {\r\n return { valid: false, reason: 'empty path' };\r\n }\r\n\r\n const filename = segments.at(-1);\r\n if (filename !== 'page.tsx' && filename !== 'page.jsx') {\r\n return { valid: false, reason: 'not a page file (expected page.tsx)' };\r\n }\r\n\r\n const folderSegments = segments.slice(0, -1);\r\n const prefix = rules.privateFolderPrefix;\r\n const reserved = rules.scaffoldIgnoredFolders;\r\n\r\n for (const reservedName of reserved) {\r\n if (folderSegments.includes(reservedName)) {\r\n return { valid: false, reason: `page.tsx cannot live inside reserved folder ${reservedName}` };\r\n }\r\n }\r\n\r\n const visibleSegments = folderSegments.filter((segment) => !segment.startsWith(prefix));\r\n\r\n if (folderSegments.length > 0 && visibleSegments.length === 0) {\r\n return { valid: false, reason: 'no URL segment left after stripping underscore folders' };\r\n }\r\n\r\n const route = visibleSegments.length === 0 ? '/' : `/${visibleSegments.join('/')}`;\r\n return { valid: true, route };\r\n};\r\n"],"mappings":"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","names":["registry","responseIndex","registry"]}