@luckystack/core 0.8.3 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/CLAUDE.md +8 -8
- package/README.md +6 -8
- package/dist/{chunk-6HGH6S36.js → chunk-7VLA5TBT.js} +23 -2
- package/dist/chunk-7VLA5TBT.js.map +1 -0
- package/dist/{chunk-I4R7DMIX.js → chunk-IJYNVM2W.js} +2 -2
- package/dist/{chunk-RH65K3US.js → chunk-OWMII7WC.js} +1 -1
- package/dist/chunk-OWMII7WC.js.map +1 -0
- package/dist/{chunk-GZEHPARN.js → chunk-VRKJHE7E.js} +3 -3
- package/dist/{chunk-GZEHPARN.js.map → chunk-VRKJHE7E.js.map} +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +4 -4
- package/dist/{config-DV8DGNt6.d.ts → config-CwW3KY9r.d.ts} +6 -2
- package/dist/config.d.ts +2 -2
- package/dist/config.js +10 -4
- package/dist/index.d.ts +10 -10
- package/dist/index.js +26 -13
- package/dist/index.js.map +1 -1
- package/dist/{projectConfig-PB5VK5AD.js → projectConfig-REH67TB2.js} +2 -2
- package/dist/{sentrySetup-G7NGISBY.js → sentrySetup-HNIURVUZ.js} +3 -3
- package/dist/{sessionTypes-iyUMf_pf.d.ts → sessionTypes-DanjuPaE.d.ts} +1 -1
- package/docs/app-bootstrap.md +10 -7
- package/docs/config-registry.md +2 -2
- package/docs/csrf-config.md +11 -11
- package/docs/hooks.md +1 -1
- package/docs/rate-limit-strategy.md +1 -1
- package/docs/redis-adapter.md +1 -1
- package/docs/session-types.md +1 -1
- package/docs/socket-bootstrap.md +3 -3
- package/package.json +1 -1
- package/dist/chunk-6HGH6S36.js.map +0 -1
- package/dist/chunk-RH65K3US.js.map +0 -1
- /package/dist/{chunk-I4R7DMIX.js.map → chunk-IJYNVM2W.js.map} +0 -0
- /package/dist/{projectConfig-PB5VK5AD.js.map → projectConfig-REH67TB2.js.map} +0 -0
- /package/dist/{sentrySetup-G7NGISBY.js.map → sentrySetup-HNIURVUZ.js.map} +0 -0
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
getProjectName,
|
|
5
5
|
isProjectConfigRegistered,
|
|
6
6
|
registerProjectConfig
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-OWMII7WC.js";
|
|
8
8
|
export {
|
|
9
9
|
DEFAULT_PROJECT_CONFIG,
|
|
10
10
|
getProjectConfig,
|
|
@@ -12,4 +12,4 @@ export {
|
|
|
12
12
|
isProjectConfigRegistered,
|
|
13
13
|
registerProjectConfig
|
|
14
14
|
};
|
|
15
|
-
//# sourceMappingURL=projectConfig-
|
|
15
|
+
//# sourceMappingURL=projectConfig-REH67TB2.js.map
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
initSharedSentry,
|
|
5
5
|
setSentryUser,
|
|
6
6
|
startSpan
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import "./chunk-
|
|
7
|
+
} from "./chunk-IJYNVM2W.js";
|
|
8
|
+
import "./chunk-OWMII7WC.js";
|
|
9
9
|
export {
|
|
10
10
|
captureException,
|
|
11
11
|
captureMessage,
|
|
@@ -13,4 +13,4 @@ export {
|
|
|
13
13
|
setSentryUser,
|
|
14
14
|
startSpan
|
|
15
15
|
};
|
|
16
|
-
//# sourceMappingURL=sentrySetup-
|
|
16
|
+
//# sourceMappingURL=sentrySetup-HNIURVUZ.js.map
|
|
@@ -194,7 +194,7 @@ interface CorsConfig {
|
|
|
194
194
|
credentials: boolean;
|
|
195
195
|
/**
|
|
196
196
|
* Origins permitted to make cross-origin requests. The framework always
|
|
197
|
-
* permits the
|
|
197
|
+
* permits the registered bind address. Set `allowLocalhost`
|
|
198
198
|
* to true to also accept any `localhost` origin (useful for dev).
|
|
199
199
|
* Origins are normalized (scheme + host + port) before comparison.
|
|
200
200
|
*
|
package/docs/app-bootstrap.md
CHANGED
|
@@ -18,11 +18,15 @@ This doc groups every slot into sections:
|
|
|
18
18
|
- **Boot UUID + synchronized env hashes** — router cross-env handshake primitives.
|
|
19
19
|
- **Misc utilities** — `tryCatch`, `sleep`, `getParams`, `serveAvatar`.
|
|
20
20
|
|
|
21
|
-
## API Reference — Bind Address
|
|
21
|
+
## API Reference — Runtime Port Override + Bind Address
|
|
22
22
|
|
|
23
|
-
### `
|
|
23
|
+
### `registerPortOverride(port: number | null): void` / `getPortOverride(): number | undefined`
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Browser-safe registry exported from both `@luckystack/core/config` and the main barrel. `@luckystack/server/parseArgv` registers the positional CLI port before consumer `config.ts` evaluates; `null` clears it. This is runtime override state only — the static default remains consumer-owned `config.ports.ts`. Values outside integer `0..65535` throw.
|
|
26
|
+
|
|
27
|
+
### `registerBindAddress(address: { ip: string; port: number; configuredPort?: number }): void`
|
|
28
|
+
|
|
29
|
+
Stores the intended pre-listen address used by `createLuckyStackServer` and resets the OAuth pre-hop baseline. Optional `configuredPort` records the consumer default below a programmatic/argv override, so OAuth can update a callback that still names that default without bypassing an unrelated local router ingress. Framework code never needs to infer the listen port from env vars.
|
|
26
30
|
|
|
27
31
|
### `registerBoundAddress(address: { ip: string; port: number }): void`
|
|
28
32
|
|
|
@@ -32,12 +36,12 @@ Called from the successful `node:http.listen` callback with `httpServer.address(
|
|
|
32
36
|
|
|
33
37
|
**Behavior (resolution order):**
|
|
34
38
|
1. Actually-bound registered value (returns `{ ip: registered.ip, port: String(registered.port) }`).
|
|
35
|
-
2. `process.env.SERVER_IP`
|
|
36
|
-
3. Fallback to `'127.0.0.1'` / `''`.
|
|
39
|
+
2. `process.env.SERVER_IP` for the pre-bootstrap bind address.
|
|
40
|
+
3. Fallback to `'127.0.0.1'` / the generic server port `'80'`. A real server registers its intended `options.port` / argv / `defaultPort` before request-time CORS or OAuth resolution.
|
|
37
41
|
|
|
38
42
|
### `resolveDevCallbackUrl(callbackUrl: string): string`
|
|
39
43
|
|
|
40
|
-
In non-production environments, rewrites a `localhost`, `127.0.0.1`, or `[::1]` OAuth callback only when its effective port still equals the intended pre-listen port and the server
|
|
44
|
+
In non-production environments, rewrites a `localhost`, `127.0.0.1`, or `[::1]` OAuth callback only when its effective port still equals the intended pre-listen port or the registered consumer default and the server uses another port. Authorize and token exchange both call this helper, so their `redirect_uri` remains byte-identical. An explicitly configured local router/reverse-proxy port that matches neither is left untouched. Production, remote hosts, malformed URLs, and already-current callbacks are no-ops.
|
|
41
45
|
|
|
42
46
|
## API Reference — Runtime Maps
|
|
43
47
|
|
|
@@ -393,7 +397,6 @@ Resolves after `setTimeout(ms)`. Used in fanout-yield loops and dev-mode hot rel
|
|
|
393
397
|
|---|---|---|
|
|
394
398
|
| `NODE_ENV` | `'development'` | `env.ts` (Zod-validated). |
|
|
395
399
|
| `SERVER_IP` | `'127.0.0.1'` | `getBindAddress` fallback. |
|
|
396
|
-
| `SERVER_PORT` | `'80'` | `getBindAddress` fallback. |
|
|
397
400
|
| `SECURE` | `'false'` | `allowedOrigin` scheme selection. |
|
|
398
401
|
| `REDIS_HOST` | `'127.0.0.1'` | Default redis client. |
|
|
399
402
|
| `REDIS_PORT` | `'6379'` | Same. |
|
package/docs/config-registry.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Config Registry
|
|
2
2
|
|
|
3
|
-
> Deep specs for project / deploy / services / avatar / paths config slots. Source: `packages/core/src/`.
|
|
3
|
+
> Deep specs for project / deploy / services / avatar / paths config slots. Source: `packages/core/src/`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -314,7 +314,7 @@ Deprecated module-level constants exist for backwards compatibility (`SRC_DIR`,
|
|
|
314
314
|
| `rateLimiting.enabled` | `boolean` | `true` | Kill-switch — when `false`, every check short-circuits to allowed. |
|
|
315
315
|
| `rateLimiting.store` | `'memory' \| 'redis'` | `'memory'` | Backend for the default strategy. |
|
|
316
316
|
| `rateLimiting.windowMs` | `number` | `60000` | Default sliding window. |
|
|
317
|
-
| `session.basedToken` | `boolean` | `false` |
|
|
317
|
+
| `session.basedToken` | `boolean` | `false` | Internal project-config slot: `true` uses tab-scoped sessionStorage + bearer/handshake auth; `false` uses an HttpOnly cookie + CSRF. The scaffold's public `config.sessionBasedToken` field maps here during registration. |
|
|
318
318
|
| `session.expiryDays` | `number` | `7` | Session TTL. |
|
|
319
319
|
| `session.perUser` | `'single' \| 'multiple'` | `'single'` | Whether a single user may be logged in on multiple devices/browsers concurrently. |
|
|
320
320
|
| `session.maxConcurrentPerUser` | `number \| null` | `null` | Cap when `perUser === 'multiple'`. |
|
package/docs/csrf-config.md
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# CSRF Config
|
|
2
2
|
|
|
3
|
-
> Customise the framework's CSRF cookie name, header name, token length and cookie options without forking `@luckystack/server`. Source: `packages/core/src/csrfConfig.ts`.
|
|
3
|
+
> Customise the framework's CSRF cookie name, header name, token length and cookie options without forking `@luckystack/server`. Source: `packages/core/src/csrfConfig.ts`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
LuckyStack
|
|
7
|
+
LuckyStack has two cookie-mode CSRF branches. With `@luckystack/login`, the login package mints a per-session token and the server compares the configured request header with the session record. Without login, `GET /auth/csrf` issues a JavaScript-readable cookie and the server timing-safely compares that double-submit cookie with the header. Token/sessionStorage mode skips CSRF because the browser does not ambiently attach the bearer token. Use `registerCsrfConfig({ ... })` for external gateway conventions or a stronger token-length policy.
|
|
8
8
|
|
|
9
9
|
| Setting | Default | Where it is consumed |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| `cookieName` | `'csrf-token'` |
|
|
12
|
-
| `headerName` | `'x-csrf-token'` |
|
|
13
|
-
| `tokenLength` | `32` (bytes) | Session
|
|
14
|
-
| `cookieOptions.sameSite` | `'lax'` |
|
|
15
|
-
| `cookieOptions.secure` | `true`
|
|
16
|
-
| `cookieOptions.httpOnly` | `false` |
|
|
17
|
-
| `cookieOptions.path` | `'/'` |
|
|
18
|
-
| `cookieOptions.maxAgeMs` | `86_400_000` (1 day) |
|
|
11
|
+
| `cookieName` | `'csrf-token'` | Login-absent double-submit issue and validation. |
|
|
12
|
+
| `headerName` | `'x-csrf-token'` | Middleware reads `req.headers[headerName]`; `httpFetch` attaches it on writes. |
|
|
13
|
+
| `tokenLength` | `32` (bytes) | Session-bound and login-absent token minting (`randomBytes(tokenLength).toString('hex')`). |
|
|
14
|
+
| `cookieOptions.sameSite` | `'lax'` | Login-absent double-submit cookie. |
|
|
15
|
+
| `cookieOptions.secure` | unset | Follows `SECURE=true` unless explicitly overridden. |
|
|
16
|
+
| `cookieOptions.httpOnly` | `false` | Compatibility default. May be `true`: the client reads the token from the same-origin JSON response, not from `document.cookie`. |
|
|
17
|
+
| `cookieOptions.path` | `'/'` | Double-submit cookie path. |
|
|
18
|
+
| `cookieOptions.maxAgeMs` | `86_400_000` (1 day) | Double-submit cookie lifetime. |
|
|
19
19
|
|
|
20
20
|
## API Reference
|
|
21
21
|
|
|
@@ -76,7 +76,7 @@ Test-only helper. Restore defaults between scenarios. Not part of the runtime co
|
|
|
76
76
|
|
|
77
77
|
## What this does NOT change
|
|
78
78
|
|
|
79
|
-
- The
|
|
79
|
+
- The validation branch itself (session-bound comparison with login; timing-safe cookie/header comparison without login).
|
|
80
80
|
- Token-mode session behaviour — token-mode sessions skip CSRF entirely (cross-origin requests don't auto-attach `sessionStorage`).
|
|
81
81
|
- The framework's HTTP route paths (`GET /auth/csrf` is still the issuing endpoint).
|
|
82
82
|
- The `csrfMismatch` hook fan-out — payload shape and timing are unchanged.
|
package/docs/hooks.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Hooks
|
|
2
2
|
|
|
3
|
-
> Deep specs for the async + sync hook registries, the canonical core-owned payload shapes, `processUpload`, and the response normalizer family. Source: `packages/core/src/hooks/`, `processUpload.ts`, `responseNormalizer.ts`.
|
|
3
|
+
> Deep specs for the async + sync hook registries, the canonical core-owned payload shapes, `processUpload`, and the response normalizer family. Source: `packages/core/src/hooks/`, `processUpload.ts`, `responseNormalizer.ts`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rate Limit Strategy
|
|
2
2
|
|
|
3
|
-
> Deep specs for the pluggable rate-limit dispatcher and built-in memory/Redis strategy. Source: `packages/core/src/rateLimiter.ts`.
|
|
3
|
+
> Deep specs for the pluggable rate-limit dispatcher and built-in memory/Redis strategy. Source: `packages/core/src/rateLimiter.ts`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
package/docs/redis-adapter.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Redis Adapter
|
|
2
2
|
|
|
3
|
-
> Deep specs for the Redis client proxy, connection options helper, and the Socket.io cross-instance adapter. Source: `packages/core/src/redis.ts`, `socketRedisAdapter.ts`.
|
|
3
|
+
> Deep specs for the Redis client proxy, connection options helper, and the Socket.io cross-instance adapter. Source: `packages/core/src/redis.ts`, `socketRedisAdapter.ts`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
package/docs/session-types.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Session Types
|
|
2
2
|
|
|
3
|
-
> Deep specs for the foundational session/auth types plus `validateRequest`, `isFalsy`, and `validateInputByType`. Source: `packages/core/src/sessionTypes.ts`, `validateRequest.ts`, `runtimeTypeValidation.ts`.
|
|
3
|
+
> Deep specs for the foundational session/auth types plus `validateRequest`, `isFalsy`, and `validateInputByType`. Source: `packages/core/src/sessionTypes.ts`, `validateRequest.ts`, `runtimeTypeValidation.ts`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
package/docs/socket-bootstrap.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Socket Bootstrap
|
|
2
2
|
|
|
3
|
-
> Deep specs for socket transport bootstrap, token extraction, CORS check, offline queue, CSRF, cookies, service-route helpers and event constants. Source: `packages/core/src/`.
|
|
3
|
+
> Deep specs for socket transport bootstrap, token extraction, CORS check, offline queue, CSRF, cookies, service-route helpers and event constants. Source: `packages/core/src/`.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -129,7 +129,7 @@ export const extractTokenFromSocket = (socket: Socket): string | null
|
|
|
129
129
|
**Behavior:**
|
|
130
130
|
- Reads `socket.handshake.auth.token` (when it is a string) and the configured cookie (`projectConfig.http.sessionCookieName`) from `socket.handshake.headers.cookie`.
|
|
131
131
|
- When `session.basedToken === true`: returns the auth-token first, falls back to the cookie.
|
|
132
|
-
- When `session.basedToken === false`: returns the cookie
|
|
132
|
+
- When `session.basedToken === false`: returns only the cookie by default; when `http.acceptBearerInCookieMode === true`, falls back to the auth-token for legacy compatibility.
|
|
133
133
|
|
|
134
134
|
**Returns:** The token string or `null`.
|
|
135
135
|
|
|
@@ -143,7 +143,7 @@ export const extractTokenFromRequest = (req: IncomingMessage): string | null
|
|
|
143
143
|
**Behavior:**
|
|
144
144
|
- Reads `req.headers.authorization` and extracts the bearer (`'Bearer <token>'`).
|
|
145
145
|
- Reads the configured cookie from `req.headers.cookie`.
|
|
146
|
-
-
|
|
146
|
+
- In token mode, prefers bearer and falls back to cookie. In cookie mode, returns the cookie and ignores bearer by default; `http.acceptBearerInCookieMode=true` enables the legacy bearer fallback.
|
|
147
147
|
|
|
148
148
|
## API Reference — Origin Check
|
|
149
149
|
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/deployConfigRegistry.ts","../src/servicesConfigRegistry.ts","../src/secretsResolved.ts"],"sourcesContent":["//? Deploy-time topology registered by the project at boot. Mirrors the\n//? `projectConfig` / `runtimeMapsRegistry` / `notifier` pattern: framework\n//? packages read deploy data through `getDeployConfig()` so they don't\n//? `import '../../../deploy.config'`.\n//?\n//? Only the fields core actually consumes are declared here. The project's\n//? richer `DeployConfig` (environments, bindings, routing, etc.) stays in\n//? `deploy.config.ts` and is consumed directly by the router package, which\n//? is project-glue (tier-B) and intentionally stays project-coupled.\n//?\n//? Project entrypoint registers via the direct file path\n//? (`./packages/core/src/deployConfigRegistry`) — same Vite-bundle rule we\n//? already use for projectConfig: importing the core barrel from a\n//? client-bundled file pulls server-only modules (Redis, paths, etc.).\n\nexport interface DeployResourceShape {\n type: 'redis' | 'mongo';\n urlEnvKey: string;\n synchronizedEnvKeys?: string[];\n}\n\nexport interface DeployEnvironmentShape {\n redis: string;\n mongo: string;\n fallback?: string;\n bindings: Record<string, string>;\n}\n\nexport interface DeployRoutingShape {\n onMissingService?: 'hard-error' | 'proxy-fallback';\n missingServiceErrorCode?: string;\n enableUnhealthyFallback?: boolean;\n strictBootHandshake?: boolean;\n /**\n * Opt INTO the boot-handshake's synchronized-env (cookie/session secret) drift\n * check. DEFAULT false — the router stays a dumb proxy that holds NO backend\n * secrets. Comparing synchronized-env hashes requires the router process to\n * carry the same SESSION_SECRET etc. as the backends, which most deployments\n * deliberately avoid; the always-on shared-Redis handshake is unaffected. Set\n * true only when you intentionally give the router the synchronized secrets.\n */\n verifySynchronizedEnv?: boolean;\n /** Default port the router listens on when ROUTER_PORT env var is unset (default 4000). */\n defaultRouterPort?: number;\n /** Default ms between health polls when not specified per-call (default 5000). */\n defaultHealthPollMs?: number;\n /** ms before the fallback `/_health` probe times out (default 3000). */\n healthProbeTimeoutMs?: number;\n /** Seconds the boot-UUID Redis key lives for (default 3600 = 1h). */\n bootKeyTtlSeconds?: number;\n /**\n * ms before the router gives up proxying to an upstream backend (SEC-30 /\n * router-DeployRoutingShape). Guards against slow-loris upstreams holding a\n * router worker open. The router reads this when building its proxy request.\n * DEFAULT undefined → router falls back to its own built-in default.\n */\n upstreamTimeoutMs?: number;\n /**\n * Name of the service that terminates websocket (Socket.io) traffic. The\n * router pins WS upgrade requests to this service (they cannot be load-balanced\n * round-robin without sticky sessions). DEFAULT undefined → router uses its\n * `system` convention.\n */\n websocketService?: string;\n /**\n * Immediate TLS proxy addresses/CIDRs allowed to assert\n * `x-forwarded-proto: https`. Empty/undefined trusts nobody, so a client\n * directly reaching the router cannot spoof secure-scheme backend logic.\n * Examples: `['127.0.0.1/32', '::1/128']` for same-host nginx/Caddy, or the\n * private subnet of a managed load balancer.\n */\n trustedProxyCidrs?: string[];\n /**\n * Path the router exposes for its OWN health/liveness (distinct from the\n * backend `/_health` boot-handshake endpoint). DEFAULT undefined → router\n * uses its built-in default.\n */\n routerHealthPath?: string;\n /**\n * Max request body size (bytes) the router will buffer/forward before\n * rejecting with 413. DEFAULT undefined → router uses its built-in default.\n */\n maxRequestBodyBytes?: number;\n /**\n * Max size (bytes) of the upgrade `head` buffer the WS proxy forwards to the\n * upstream during a WebSocket handshake. An over-cap head is rejected (the\n * client socket is destroyed) BEFORE the upstream leg opens, so a client\n * cannot push an unbounded pre-upgrade buffer through the router. DEFAULT\n * undefined → router uses its built-in default (64 KiB).\n */\n wsMaxHeadBytes?: number;\n /**\n * Idle-timeout (ms) for an UPGRADED WebSocket pipe: when no bytes flow in\n * either direction for this long, the router tears the proxied pair down.\n * Bounds an otherwise-unbounded pre/post-auth pipe a client could hold open\n * against the router. The timer resets on any activity. DEFAULT undefined →\n * router uses its built-in default (120000 ms = 2 min). Set `0` to disable.\n */\n wsIdleTimeoutMs?: number;\n /**\n * Per-connection byte budget for an UPGRADED WebSocket pipe: when the total\n * bytes piped across both legs exceed this cap, the router tears the proxied\n * pair down. Bounds how much a single connection can stream through the\n * router. DEFAULT undefined → router uses its built-in default (100 MiB). Set\n * `false` to disable the cap (unbounded).\n */\n wsMaxBytesPerConnection?: number | false;\n}\n\nexport interface DeployDevelopmentShape {\n enableFallbackRouting?: boolean;\n healthPollMs?: number;\n switchNewTrafficToLocalWhenHealthy?: boolean;\n}\n\nexport interface DeployConfigShape {\n resources: Record<string, DeployResourceShape>;\n environments?: Record<string, DeployEnvironmentShape>;\n routing?: DeployRoutingShape;\n development?: DeployDevelopmentShape;\n}\n\nimport { createRegistry } from './createRegistry';\n\nconst DEFAULT_DEPLOY_CONFIG: DeployConfigShape = {\n resources: {},\n};\n\nconst registry = createRegistry<DeployConfigShape>(DEFAULT_DEPLOY_CONFIG);\n\nexport const registerDeployConfig = (config: DeployConfigShape): DeployConfigShape =>\n registry.register(config);\n\nexport const getDeployConfig = (): DeployConfigShape => registry.get();\n\nexport const isDeployConfigRegistered = (): boolean => registry.isRegistered();\n","//? Services config registry. The router (and presetLoader) consume the\n//? consumer's services topology — which services exist, which presets bundle\n//? them together — through this registry instead of a relative\n//? `../../../services.config` import.\n//?\n//? The consumer's `services.config.ts` registers its config at module import\n//? (side-effect pattern, identical to `deploy.config.ts:151`). Router code\n//? calls `getServicesConfig()` at request time.\n\nexport interface ServiceDefinition {\n /** 'root' -> reserved for the system service. Otherwise a folder name under srcDir. */\n // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- 'root' is documented as the reserved value; the union surfaces it in autocomplete\n source: 'root' | string;\n}\n\nexport interface PresetDefinition {\n description?: string;\n /** Services that are bundled together into one backend artifact. */\n services: string[];\n}\n\nexport interface ServicesConfigShape {\n services: Record<string, ServiceDefinition>;\n presets: Record<string, PresetDefinition>;\n /**\n * Pure-data ownership manifest for HTTP routes outside `/api/*` and `/sync/*`.\n * Keys are absolute path prefixes; values are service keys. The router uses\n * longest-prefix matching, so `/webhooks/admin` may override `/webhooks`.\n */\n customRoutes?: Record<string, string>;\n}\n\nimport { createRegistry } from './createRegistry';\n\nconst registry = createRegistry<ServicesConfigShape>(\n //? Unregistered sentinel — `get` never returns this because `resolveDefault`\n //? throws first. Typed as the real shape so the slot stays well-typed.\n { services: {}, presets: {} },\n {\n resolveDefault: (): ServicesConfigShape => {\n throw new Error(\n '@luckystack/core: services config has not been registered. Call ' +\n '`registerServicesConfig({...})` from your `services.config.ts` (which should be ' +\n 'imported as a side-effect during boot).'\n );\n },\n },\n);\n\nexport const registerServicesConfig = (config: ServicesConfigShape): ServicesConfigShape =>\n registry.register(config);\n\nexport const getServicesConfig = (): ServicesConfigShape => registry.get();\n\nexport const isServicesConfigRegistered = (): boolean => registry.isRegistered();\n","//? Decoupled \"secrets were (re)resolved\" notification channel.\n//?\n//? A secret resolver fires `notifySecretsResolved(changedKeys)` right after it\n//? overwrites `process.env` with the real values (at boot AND on rotation).\n//? `@luckystack/secret-manager` fires it AUTOMATICALLY after every resolve (via\n//? the global-symbol channel at the bottom of this file — no import edge, no\n//? `onApplied` wiring needed).\n//? Listeners then drop any client they built from the PRE-resolution env so the\n//? next use rebuilds with the resolved secret.\n//?\n//? This is deliberately generic and lives in core with ZERO dependency on\n//? secret-manager: core self-registers a Redis listener (see `redis.ts`), and\n//? any other cached-client owner (a Prisma pool, an SDK client) can subscribe\n//? the same way without coupling to the resolver package.\n\nexport type SecretsResolvedListener = (changedKeys: readonly string[] | undefined) => void;\n\nconst listeners = new Set<SecretsResolvedListener>();\n\n//? Subscribe to secret-resolution events. Returns an unsubscribe function.\nexport const registerSecretsResolvedListener = (listener: SecretsResolvedListener): (() => void) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n};\n\n//? Fire all listeners. `changedKeys` = the env NAMES whose value actually\n//? changed (never the secret values). Omit it (`undefined`) when the caller\n//? doesn't know which keys changed — listeners then treat it as \"assume\n//? relevant\" and reset defensively.\nexport const notifySecretsResolved = (changedKeys?: readonly string[]): void => {\n for (const listener of listeners) {\n try {\n listener(changedKeys);\n } catch {\n //? A misbehaving listener must never break the resolve/boot path.\n }\n }\n};\n\n//? Cross-PACKAGE channel: publish THIS core instance's `notifySecretsResolved`\n//? on a well-known global-symbol array so a decoupled package that must not\n//? depend on `@luckystack/core` — notably `@luckystack/secret-manager`, the\n//? zero-dependency resolver — can fire it right after it resolves secrets, with\n//? NO import edge. An ARRAY (not a single slot) so that if the process somehow\n//? has more than one `@luckystack/core` instance (dual-package / mixed src+dist\n//? resolution — a real footgun that also causes the env-revert), EVERY instance's\n//? redis registry gets the rebuilt client, not just the last one loaded.\nconst GLOBAL_LISTENERS_SYMBOL = Symbol.for('luckystack.secretsResolved.listeners');\n\nconst getGlobalSecretsResolvedListeners = (): SecretsResolvedListener[] => {\n const existing: unknown = Reflect.get(globalThis, GLOBAL_LISTENERS_SYMBOL);\n if (Array.isArray(existing)) return existing as SecretsResolvedListener[];\n const created: SecretsResolvedListener[] = [];\n Reflect.set(globalThis, GLOBAL_LISTENERS_SYMBOL, created);\n return created;\n};\n\ngetGlobalSecretsResolvedListeners().push(notifySecretsResolved);\n"],"mappings":";;;;;AA4HA,IAAM,wBAA2C;AAAA,EAC/C,WAAW,CAAC;AACd;AAEA,IAAM,WAAW,eAAkC,qBAAqB;AAEjE,IAAM,uBAAuB,CAAC,WACnC,SAAS,SAAS,MAAM;AAEnB,IAAM,kBAAkB,MAAyB,SAAS,IAAI;AAE9D,IAAM,2BAA2B,MAAe,SAAS,aAAa;;;ACrG7E,IAAMA,YAAW;AAAA;AAAA;AAAA,EAGf,EAAE,UAAU,CAAC,GAAG,SAAS,CAAC,EAAE;AAAA,EAC5B;AAAA,IACE,gBAAgB,MAA2B;AACzC,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AAAA,EACF;AACF;AAEO,IAAM,yBAAyB,CAAC,WACrCA,UAAS,SAAS,MAAM;AAEnB,IAAM,oBAAoB,MAA2BA,UAAS,IAAI;AAElE,IAAM,6BAA6B,MAAeA,UAAS,aAAa;;;ACrC/E,IAAM,YAAY,oBAAI,IAA6B;AAG5C,IAAM,kCAAkC,CAAC,aAAoD;AAClG,YAAU,IAAI,QAAQ;AACtB,SAAO,MAAM;AACX,cAAU,OAAO,QAAQ;AAAA,EAC3B;AACF;AAMO,IAAM,wBAAwB,CAAC,gBAA0C;AAC9E,aAAW,YAAY,WAAW;AAChC,QAAI;AACF,eAAS,WAAW;AAAA,IACtB,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAUA,IAAM,0BAA0B,uBAAO,IAAI,sCAAsC;AAEjF,IAAM,oCAAoC,MAAiC;AACzE,QAAM,WAAoB,QAAQ,IAAI,YAAY,uBAAuB;AACzE,MAAI,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACpC,QAAM,UAAqC,CAAC;AAC5C,UAAQ,IAAI,YAAY,yBAAyB,OAAO;AACxD,SAAO;AACT;AAEA,kCAAkC,EAAE,KAAK,qBAAqB;","names":["registry"]}
|
|
@@ -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\n//? (projectConfig, avatarConfig, and — after this lands — email/presence/\n//? error-tracking) deep-merges a consumer's deep-partial override over a\n//? built-in default. This module is the single implementation so a merge\n//? bug is fixed once for everyone.\n//?\n//? Behaviour contract (must match the historical hand-rolled copies exactly):\n//? - `override === undefined` → return `base` untouched.\n//? - When either side is not a plain object (arrays, primitives, class\n//? instances, null) → the override replaces the base wholesale\n//? (`override ?? base`). Arrays are therefore replaced, never merged.\n//? - Otherwise merge key-by-key: `undefined` override values are skipped\n//? (the base value wins), nested plain objects recurse, everything else\n//? replaces.\n//?\n//? Added over the historical copies: a depth + circular-reference guard so a\n//? pathological override (self-referential object or absurd nesting) degrades\n//? to a wholesale replace instead of overflowing the stack. Real config trees\n//? are a handful of levels deep, so the guard never triggers in practice and\n//? behaviour is identical to the old implementations.\n\n/** Maximum recursion depth before the merge stops descending and replaces wholesale. */\nconst MAX_MERGE_DEPTH = 100;\n\nexport type DeepPartial<T> = {\n [K in keyof T]?: T[K] extends object | undefined ? DeepPartial<NonNullable<T[K]>> : T[K];\n};\n\n/**\n * True when `value` is a plain object literal (prototype is `Object.prototype`\n * or `null`). Arrays, class instances, Maps, Dates, and `null` are rejected so\n * they are treated as opaque replacement values during a merge.\n */\nexport const isPlainObject = (value: unknown): value is Record<string, unknown> => {\n if (value === null || typeof value !== 'object') return false;\n const proto = Object.getPrototypeOf(value) as object | null;\n return proto === Object.prototype || proto === null;\n};\n\nconst deepMergeInternal = <T>(\n base: T,\n override: DeepPartial<T> | undefined,\n depth: number,\n seen: WeakSet<object>,\n): T => {\n if (override === undefined) return base;\n if (!isPlainObject(base) || !isPlainObject(override)) {\n return (override as T) ?? base;\n }\n\n //? Depth / circular guard: if we have descended too far, or the override\n //? object has already been visited on this branch (self-reference), stop\n //? recursing and replace wholesale. Normal config trees never reach this.\n if (depth >= MAX_MERGE_DEPTH || seen.has(override)) {\n return (override as T) ?? base;\n }\n seen.add(override);\n try {\n const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };\n for (const [key, overrideValue] of Object.entries(override as Record<string, unknown>)) {\n if (overrideValue === undefined) continue;\n //? Prototype-pollution guard: only `__proto__` can silently mutate the\n //? prototype chain via object spread/assign. `constructor` and `prototype`\n //? are valid config key names (e.g. a config section named `prototype` or a\n //? field called `constructor`) and must be allowed through; blocking them\n //? silently dropped legitimate consumer config keys (CORE-N5).\n if (key === '__proto__') continue;\n const baseValue = (base as Record<string, unknown>)[key];\n result[key] =\n isPlainObject(baseValue) && isPlainObject(overrideValue)\n ? deepMergeInternal(baseValue, overrideValue as DeepPartial<unknown>, depth + 1, seen)\n : overrideValue;\n }\n return result as T;\n } finally {\n //? Remove on the way back UP so the visited-set tracks only the ACTIVE branch\n //? (ancestor cycle), not the whole tree. Without this, a shared (DAG) object\n //? reference reused at two sibling config positions hit `seen.has()` on its\n //? 2nd occurrence and was replaced wholesale — silently dropping that\n //? sub-tree's base defaults (could relax a security default). Per-branch\n //? tracking still catches a genuine self-reference (object on the live path).\n seen.delete(override);\n }\n};\n\n/**\n * Deep-merge a deep-partial `override` over a fully-populated `base`,\n * returning a new object. See the module header for the exact behaviour\n * contract. Arrays and non-plain-object values are replaced wholesale; nested\n * plain objects merge recursively; `undefined` override values are ignored.\n */\nexport const deepMerge = <T>(base: T, override: DeepPartial<T> | undefined): T =>\n deepMergeInternal(base, override, 0, new WeakSet<object>());\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\n//? `getProjectConfig()`. The project's entrypoint calls\n//? `registerProjectConfig({...})` once at boot — before any framework code\n//? issues API/sync requests — mirroring the pattern we use for the localized\n//? normalizer.\n//?\n//? Everything here is runtime-settable. Type-level project shapes\n//? (`SessionLayout`, etc.) stay in the project's `config.ts` so TypeScript\n//? inference keeps working at call sites.\n//?\n//? Framework code reads values through `getProjectConfig()` (call-time) so\n//? `registerProjectConfig` can be invoked after this module is imported.\n//? Never read at module load — that captures whatever was registered at\n//? import time, which is fragile.\n//?\n//? `registerProjectConfig` accepts a deep-partial of `ProjectConfig`; missing\n//? fields fall back to the defaults defined here. This means a project's\n//? `config.ts` only has to specify the values it wants to override.\n\nimport { deepMerge, type DeepPartial } from './configUtils';\nimport { createRegistry } from './createRegistry';\n\nexport interface LoggingConfig {\n devLogs: boolean;\n devNotifications: boolean;\n socketStatus: boolean;\n socketStartup: boolean;\n stream: boolean;\n /**\n * Prefix every line from the framework's built-in loggers (the default\n * `console.*` logger and `createDevLogger`) with an ISO-8601 UTC timestamp,\n * e.g. `[2026-07-13T15:20:01.123Z] Connected to Redis`. DEFAULT `true`. Set\n * `false` under a log aggregator that stamps its own time (journald, Docker,\n * Datadog) to avoid double timestamps. A registered custom logger\n * (`registerLogger(...)`) is responsible for its own timestamps — this flag\n * only governs the built-in loggers.\n */\n timestamps: boolean;\n}\n\nexport interface RateLimitingConfig {\n /**\n * Global kill-switch for all rate limiting. When `false`, every\n * `checkRateLimit` call short-circuits to \"allowed\" and counters are not\n * touched. Useful for local dev, load tests, or trusted internal tooling.\n * Defaults to `true`.\n */\n enabled: boolean;\n store: 'memory' | 'redis';\n redisKeyPrefix: string;\n defaultApiLimit: number | false;\n defaultIpLimit: number | false;\n windowMs: number;\n /** How often the in-memory store evicts expired entries. */\n cleanupIntervalMs: number;\n /**\n * What the default strategy does when `store: 'redis'` is active but a Redis\n * operation fails (disconnect, eval error). DEFAULT `'memory'` — degrade to\n * the process-local in-memory store (fail-open, backwards-compatible) and log\n * a one-shot warning. In a multi-instance deployment this silently relaxes the\n * global limit to per-instance (N×). Set `'deny'` to instead reject the\n * request (fail-closed) so a Redis outage can't bypass rate limiting.\n */\n onStoreError: 'memory' | 'deny';\n /**\n * Skip rate limiting for requests that resolve to a loopback client IP\n * (`127.0.0.1`, `::1`, the `UNKNOWN_CLIENT_IP` sentinel) when running in\n * development (`NODE_ENV !== 'production'`). Lets a dev machine hammer its own\n * server (HMR reload storms, local load tests) without tripping limits, while\n * staying fully enforced in production. DEFAULT `false` (enforce everywhere) —\n * a missing key keeps today's behavior. The api/sync transport handlers read\n * this when building the per-IP key; core only owns the flag + the\n * {@link isLoopbackIp} helper.\n */\n skipLoopbackInDev: boolean;\n /**\n * Identity callback that decides the BASIS of a rate-limit key for a given\n * request — i.e. whether to key per-user, per-IP, or some custom dimension.\n * The api/sync handlers call this (when set) to derive the `{ scope, id }`\n * used to build the bucket key, instead of the built-in user-then-IP default.\n * Returning `null` falls back to the framework default. Synchronous by design\n * (it runs on the hot request path). DEFAULT unset.\n */\n identity?: (params: RateLimitIdentityParams) => RateLimitIdentity | null;\n /**\n * Per-account brute-force lockout slot, keyed independently from the general\n * api/ip limits so credential endpoints (login, password-reset) can apply a\n * stricter window without affecting normal traffic.\n *\n * NOTE — DD-CORE-D3: **core does NOT consume this slot**. The core package\n * only declares and stores the config; the `@luckystack/login` package reads\n * `rateLimiting.auth` when keying a per-account attempt counter\n * (`auth:<accountKey>`). If you search the core source for usages of this\n * field and find none, that is expected — it is not dead config; it is login-\n * owned config that lives here for a single source of truth.\n *\n * DEFAULT `{ enabled: false }` — a missing/disabled slot keeps today's\n * behavior (no per-account lockout; only the per-IP throttle applies).\n */\n auth: AuthRateLimitConfig;\n}\n\n/** Inputs handed to the {@link RateLimitingConfig.identity} callback. */\nexport interface RateLimitIdentityParams {\n /** Resolved route name (`api/billing/getInvoice/v1`, `sync/...`). */\n routeName: string;\n /** Session user id when the request is authenticated, else null. */\n userId: string | null;\n /** Resolved client IP (already honours `http.trustProxy`). */\n ip: string;\n /** Transport the request arrived on. */\n transport: 'socket' | 'http';\n}\n\n/** Result of a {@link RateLimitingConfig.identity} callback. */\nexport interface RateLimitIdentity {\n /** Which dimension the key is built from. */\n scope: 'user' | 'ip' | 'custom';\n /** The identity value (user id, ip, or a custom string). Never a raw token. */\n id: string;\n}\n\nexport interface AuthRateLimitConfig {\n /** When false (default), no per-account lockout is applied. */\n enabled: boolean;\n /** Max failed attempts per account FROM ONE IP within `windowMs` before that\n * IP+account bucket locks (per-IP cap; also bounds a single IP and protects other\n * IPs from a victim-lock DoS). Default 5. */\n maxAttempts: number;\n /**\n * Cross-IP cap: max failed attempts against a SINGLE account from ALL IPs\n * combined within `windowMs` before the account locks. This is the\n * distributed-credential-stuffing defense — `maxAttempts` alone is per-IP, so N\n * attacker IPs would otherwise each get a fresh `maxAttempts` budget. Should be\n * HIGHER than `maxAttempts` (it counts every IP). Default 50.\n */\n maxAttemptsPerAccount: number;\n /** Rolling window in ms over which both caps are counted. Default 900000 (15 min). */\n windowMs: number;\n}\n\nexport interface SessionConfig {\n basedToken: boolean;\n expiryDays: number;\n /**\n * Whether a single user can hold multiple active sessions across devices.\n * `'single'` (default): logging in on a new device kicks the previous one.\n * `'multiple'`: device A and device B can both be logged in concurrently.\n */\n perUser: 'single' | 'multiple';\n /**\n * Optional cap on concurrent sessions when `perUser === 'multiple'`. Null\n * = unlimited. When the cap is reached, the policy from `onConflict`\n * decides whether the new login is accepted (kicking oldest) or rejected.\n */\n maxConcurrentPerUser: number | null;\n /**\n * What to do when a session limit is exceeded.\n * `'revokeOld'` (default): kick the oldest session, accept the new login.\n * `'rejectNew'`: refuse the new login, leave existing sessions intact.\n */\n onConflict: 'revokeOld' | 'rejectNew';\n /**\n * When `onConflict === 'revokeOld'`, whether to broadcast a short UI\n * notification (\"logged in elsewhere\") to the kicked device before\n * disconnecting it. Default true.\n */\n notifyOldDeviceOnRevoke: boolean;\n /**\n * Prefix used for Redis session/activeUsers keys (e.g. `${projectName}-session:<token>`).\n * Falls back to `process.env.PROJECT_NAME` then this default at config build time.\n */\n projectName: string;\n}\n\nexport interface AppConfig {\n /**\n * Public URL of the app — used by OAuth callback redirects, transactional\n * email links (when `@luckystack/email` is installed), and any other\n * framework code that needs to render an absolute link to your app.\n * Empty default — consumers should set this to the public origin of their\n * deployment (e.g. `https://app.example.com`).\n */\n publicUrl: string;\n}\n\nexport interface HttpStreamConfig {\n /** Query-string flag clients pass to opt into SSE streaming. */\n queryParam: string;\n /** Value the query-string flag must have. */\n enabledValue: string;\n /** Initial comment line written when an SSE stream opens (keeps proxies happy). */\n connectedComment: string;\n}\n\nexport interface SecurityHeadersConfig {\n frameOptions: string;\n referrerPolicy: string;\n xssProtection: string;\n contentTypeOptions: string;\n}\n\nexport interface CorsConfig {\n allowedMethods: string;\n allowedHeaders: string;\n exposedHeaders: string;\n /** Send `Access-Control-Allow-Credentials: true`. */\n credentials: boolean;\n /**\n * Origins permitted to make cross-origin requests. The framework always\n * permits the `SERVER_IP:SERVER_PORT` it binds to. Set `allowLocalhost`\n * to true to also accept any `localhost` origin (useful for dev).\n * Origins are normalized (scheme + host + port) before comparison.\n *\n * Static list mode: pass a `string[]`. Origins are matched exactly.\n * Dynamic mode: pass a synchronous function `(origin) => boolean` for\n * per-tenant / per-time-window allow-listing. Async resolvers are NOT\n * supported here because Socket.io's CORS check is synchronous; use a\n * pre-computed in-memory cache that the function can read from.\n */\n allowedOrigins: string[] | ((origin: string) => boolean);\n /**\n * Accept any origin matching `localhost` (any port). Convenient for local\n * development; should be `false` in production. Defaults to `false` so\n * production deployments fail closed.\n */\n allowLocalhost: boolean;\n /**\n * Historical opt-in kept for symmetry. Origin-less requests are ALWAYS\n * admitted at the CORS layer now (see `loadSocket.ts`): browsers omit the\n * `Origin` header on same-origin GETs, which is exactly the initial Socket.io\n * polling handshake in both dev (Vite proxy) and prod-with-router (single\n * origin) topologies — rejecting it broke every connection with\n * `400 code:3 MIDDLEWARE_FAILURE`. The real auth gate is the session token\n * in the handshake, not the Origin header. This flag no longer gates\n * anything and is retained only so existing configs that set it keep\n * type-checking; it may be removed in a future major version.\n */\n allowOriginless?: boolean;\n /**\n * Optional per-route override hook. When a `_api/*` file exports\n * `export const cors = { allowedOrigins, allowedHeaders, ... }` the\n * framework merges that on top of the global config for THAT route only.\n * Implemented in the api handler — this field is documentation-only.\n */\n // Note: per-route override is wired in `packages/api/src/handleHttpApiRequest.ts`.\n}\n\nexport interface HttpConfig {\n sessionCookieName: string;\n sessionCookieSameSite: 'Strict' | 'Lax' | 'None';\n sessionCookiePath: string;\n /**\n * Optional `Domain` attribute for the session cookie (CORE-39). Leave unset\n * (DEFAULT) for a host-only cookie (most secure). Set to a parent domain\n * (`.example.com`) only when the cookie must be shared across subdomains.\n * IGNORED when `sessionCookiePrefix === '__Host-'` (that prefix forbids\n * `Domain`). A missing key keeps today's host-only behavior.\n */\n sessionCookieDomain?: string;\n /**\n * Optional cookie name prefix enforcing browser-level guarantees (CORE-10/39):\n *\n * - `'__Host-'` — forces `Secure`, `Path=/`, and NO `Domain` (blocks\n * subdomain cookie-tossing). The strongest option for a host-only session.\n * - `'__Secure-'` — forces `Secure` (cookie only sent over HTTPS).\n * - unset (DEFAULT) — no prefix; today's behavior. A missing key changes\n * nothing.\n *\n * The server's cookie builder (`buildSessionCookie`) reads this and applies\n * the prefix to `sessionCookieName` plus the forced attributes. Use\n * {@link applyCookiePrefixConstraints} to compute the effective attributes.\n */\n sessionCookiePrefix?: '__Host-' | '__Secure-';\n /**\n * Per-cookie `Secure` override (CORE-39). When unset (DEFAULT) the server\n * derives `Secure` from `process.env.SECURE` as today. Set explicitly to\n * force on/off independently of the env flag. A `__Host-`/`__Secure-` prefix\n * forces `Secure: true` regardless.\n */\n sessionCookieSecure?: boolean;\n /**\n * Maximum body size (bytes) accepted on the HTTP transport for `/api/*` and\n * `/sync/*` POSTs. DEFAULT 1 MiB (1024 * 1024). This is the HTTP-body cap; the\n * SOCKET transport has a SEPARATE, independently-configurable frame cap\n * (`socket.maxHttpBufferSize`, default 5 MiB) — the two are deliberately distinct\n * knobs (HTTP body parser vs Socket.io whole-frame buffer). Set both to the same\n * value if you want a uniform per-call payload ceiling across transports.\n */\n requestBodyMaxBytes: number;\n /** Path of the router boot-handshake endpoint. */\n healthEndpoint: string;\n /**\n * Liveness endpoint. Always returns 200 if the process is up and\n * responsive — does NOT check dependencies. Suitable for K8s `livenessProbe`.\n */\n liveEndpoint: string;\n /**\n * Readiness endpoint. Returns 200 only when Redis + Prisma are reachable\n * AND boot UUID is set. Suitable for K8s `readinessProbe`.\n */\n readyEndpoint: string;\n /** Path of the dev-only state-reset endpoint (gated by NODE_ENV + token). */\n testResetEndpoint: string;\n /**\n * Whether a known reverse proxy sits in front of this server. DEFAULT false.\n * When false, per-IP rate-limit keys derive from the raw transport peer\n * address (historical behaviour). When true, the framework resolves the real\n * client IP from `X-Forwarded-For` / `X-Real-IP` before keying — so per-IP\n * limits stay meaningful behind nginx/HAProxy. Only enable when a trusted\n * proxy populates those headers, otherwise clients can spoof their IP.\n */\n trustProxy?: boolean;\n /**\n * How many proxy hops to skip from the RIGHT of `X-Forwarded-For` when\n * `trustProxy` is on (CORE-O3). The rightmost entries are the ones appended by\n * YOUR trusted proxies, so the resolved client IP is the entry that many hops\n * in from the end. The LEFTMOST hop is client-controlled and must never be\n * trusted (it enables per-IP rate-limit evasion + audit-IP spoofing).\n *\n * DEFAULT 1 — with the standard single-trusted-proxy topology this selects the\n * immediate upstream peer (the rightmost real hop), i.e. the IP the proxy saw\n * the connection arrive from. Raise it to match the number of trusted proxies\n * in the chain. Clamped to the list length so an over-large count falls back\n * to the leftmost available trusted hop rather than the spoofable client entry.\n * IGNORED when `trustProxy` is false (raw peer address is used verbatim).\n */\n trustedProxyHopCount?: number;\n /**\n * Whether cookie-mode sessions (`session.basedToken === false`) also accept a\n * token supplied via `Authorization: Bearer` / `handshake.auth.token` as a\n * fallback (CORE-O10). DEFAULT false — in cookie-mode the framework reads ONLY\n * the session cookie and ignores any bearer/handshake-auth token, so a stolen\n * token replayed through a header can no longer defeat the cookie/CSRF model.\n * Set true to restore the legacy behaviour (cookie-then-bearer fallback).\n * IGNORED in token-mode (`session.basedToken === true`), which is unaffected:\n * it always reads the bearer/handshake token first, cookie as fallback.\n */\n acceptBearerInCookieMode?: boolean;\n /**\n * Controls how `/_health` exposes synchronized-env-var hashes (SEC-13).\n * Previously `/_health` returned an UNSALTED `sha256(value)` of each\n * synchronized secret, unauthenticated — enabling offline dictionary attacks\n * on low-entropy secrets + key-name disclosure. This config + the shared\n * {@link hashSynchronizedValue} helper let both the server (`/_health`) and the\n * router (boot handshake) salt/HMAC consistently so the compare still works.\n * DEFAULTS keep today's wire behavior (`mode: 'plain'`) so a missing key does\n * not break an existing router handshake — opt into `'salted'`/`'hmac'`\n * explicitly (and bump server + router together).\n */\n healthHash: HealthHashConfig;\n stream: HttpStreamConfig;\n securityHeaders: SecurityHeadersConfig;\n cors: CorsConfig;\n}\n\nexport interface HealthHashConfig {\n /**\n * - `'plain'` — unsalted `sha256(value)`; the pre-0.2.0 behavior, kept so an\n * existing router boot handshake keeps comparing successfully. Opt in with\n * `mode:'plain'` to restore the legacy wire output.\n * - `'salted'` — `sha256(salt + value)` using `salt` below (or the boot UUID\n * when `salt` is the literal `'@bootUuid'`). Stable across a boot, rotates on\n * restart when bound to the boot UUID.\n * - `'hmac'` (DEFAULT in 0.2.0) — `HMAC-SHA256(key=salt, value)`. The default\n * `salt:'@bootUuid'` keys it on the per-boot UUID server + router already\n * share, so `/_health` no longer exposes a stable, offline-attackable\n * `sha256(secret)`. Collapses to `'plain'` when no boot UUID is available so\n * the boot handshake never silently diverges.\n */\n mode: 'plain' | 'salted' | 'hmac';\n /**\n * Shared salt / HMAC key. Both the backend `/_health` and the router's\n * compare MUST use the same value. The literal `'@bootUuid'` is a sentinel\n * meaning \"use the current boot UUID as the salt\" (valid with `'salted'` or\n * `'hmac'`). DEFAULTS to `'@bootUuid'` in 0.2.0 (only consulted when\n * `mode !== 'plain'`); set a non-empty static value to pin a stable key.\n */\n salt: string;\n}\n\nexport interface PasswordPolicyConfig {\n /** Minimum total length. Default 8. */\n minLength: number;\n /** Maximum total length. Default 191 (DB column-friendly). */\n maxLength: number;\n /** Require at least one uppercase letter (A-Z). Default false. */\n requireUppercase: boolean;\n /** Require at least one lowercase letter (a-z). Default false. */\n requireLowercase: boolean;\n /** Require at least one digit (0-9). Default false. */\n requireNumber: boolean;\n /** Require at least one non-alphanumeric character. Default false. */\n requireSpecial: boolean;\n /**\n * Reject the 10k most common passwords (built-in list shipped with\n * `@luckystack/login`). Default true. Set to false in tests or when\n * onboarding from a legacy system that allowed weak passwords.\n */\n forbidCommon: boolean;\n /**\n * Optional consumer-supplied predicate. Return null when the password is\n * acceptable, or a string describing why it isn't (the string is used as\n * the i18n errorCode-style key surfaced to the client).\n */\n customValidator?: (password: string) => string | null;\n}\n\nexport interface AuthConfig {\n /**\n * Whether email+password (credentials) auth is enabled. When `true` (default)\n * `@luckystack/login/register` registers the credentials provider, so it shows\n * up in `GET /auth/providers` and the login form renders the email/password\n * fields. Set `false` for an OAuth-only app — the form hides the fields AND the\n * `/auth/api/credentials` route rejects (`auth.credentialsDisabled`).\n */\n credentials: boolean;\n /** TTL for OAuth state tokens stored in Redis. */\n oauthStateTtlSeconds: number;\n /**\n * @deprecated Read `passwordPolicy.minLength`. Kept as a top-level field\n * for older consumer code; new code should use the structured policy.\n */\n passwordMinLength: number;\n /**\n * @deprecated Read `passwordPolicy.maxLength`. Kept as a top-level field\n * for older consumer code; new code should use the structured policy.\n */\n passwordMaxLength: number;\n /** Full credentials password policy. Replaces the deprecated min/max-length pair. */\n passwordPolicy: PasswordPolicyConfig;\n /** Maximum email length for credentials auth. */\n emailMaxLength: number;\n /** Maximum display-name length for credentials auth. */\n nameMaxLength: number;\n /**\n * bcrypt cost factor used when hashing passwords (registration, password\n * change, password reset). Default 10 matches industry guidance for 2026\n * hardware. Bump to 12 for higher-value accounts; 4 is acceptable for tests\n * to keep them fast.\n */\n bcryptRounds: number;\n /**\n * `'per-provider'` (default) — same email via Google and GitHub creates\n * two separate User rows (current behavior, no schema change).\n * `'unified'` — same email maps to a single User; subsequent sign-ins via\n * different providers resolve to (link to) that same User row instead of\n * creating a duplicate. Requires `email` to be `@unique` — see the\n * \"Account strategy\" migration steps in @luckystack/login's README.\n */\n providerAccountStrategy: 'per-provider' | 'unified';\n /**\n * `'framework'` — login package ships the /reset-password pages + APIs and\n * sends the reset email. Requires a registered email sender.\n * `'custom'` — login exposes only the primitives\n * (`createPasswordResetToken`, `consumePasswordResetToken`,\n * `updatePasswordHash`); the consumer wires their own UI and emails.\n * `'disabled'` (default) — no forgot-password feature; the link in\n * `LoginForm` does not render.\n */\n forgotPassword: 'framework' | 'custom' | 'disabled';\n /** Reset-token TTL in seconds when `forgotPassword === 'framework'`. */\n passwordResetTtlSeconds: number;\n /**\n * Brand/display name used in framework-mode password-reset emails (subject +\n * greeting + footer). Falls back to `'LuckyStack'` if unset; consumers should\n * override this to their own brand.\n */\n passwordResetBrand?: string;\n /** Email-change confirmation-token TTL in seconds. Default 3600 (1 hour). */\n emailChangeTtlSeconds: number;\n /**\n * Whether public self-service registration is permitted (login F18). When\n * `false`, the credentials `/register` route rejects with\n * `auth.registrationDisabled` and the login UI hides the \"create account\"\n * affordance — useful for invite-only / admin-provisioned apps. OAuth-driven\n * first-login account creation is governed separately by the provider flow.\n * DEFAULT `true` (today's behavior — open registration).\n */\n allowRegistration: boolean;\n /**\n * Frontend path the framework-mode password-reset email links to (login F22).\n * The reset token is appended as a query param. DEFAULT `/reset-password`.\n * Override when your reset page lives at a different route.\n */\n passwordResetPath: string;\n /**\n * Frontend path the email-change confirmation email links to (login F22).\n * The confirmation token is appended as a query param.\n * DEFAULT `/confirm-email-change`.\n */\n emailChangeConfirmPath: string;\n /**\n * Passwordless email-code login (ADR 0024): the user enters their email,\n * receives a short numeric code, and signs in by typing it. Requires a\n * registered email sender (`@luckystack/email`). DEFAULT `false`.\n * Anti-enumeration: the request endpoint always answers \"sent\".\n */\n emailCodeLogin: boolean;\n /** Email-code TTL in seconds (login + 2FA fallback codes). DEFAULT 600. */\n emailCodeTtlSeconds: number;\n /** Email-code length in digits. DEFAULT 6. */\n emailCodeLength: number;\n /** Wrong-code attempts before an email code is burned. DEFAULT 5. */\n emailCodeMaxAttempts: number;\n /**\n * Second factor at login (ADR 0024). `'optional'` = per-user opt-in: a user\n * who enrolled an authenticator app (TOTP — Google/Microsoft Authenticator,\n * Authy, …) must answer a 2FA challenge after their password/email-code is\n * verified. `'disabled'` (DEFAULT) = the challenge step never triggers, even\n * for enrolled users (kill switch).\n */\n twoFactor: 'disabled' | 'optional';\n /**\n * Allow \"send the code to my email instead\" as a 2FA fallback channel for\n * enrolled users (lost phone). Requires a registered email sender.\n * DEFAULT `true` (recovery codes always work regardless).\n */\n twoFactorEmailFallback: boolean;\n /** Pending 2FA login-challenge TTL in seconds. DEFAULT 300. */\n twoFactorChallengeTtlSeconds: number;\n /** Wrong-code attempts before a pending 2FA challenge is burned. DEFAULT 5. */\n twoFactorMaxAttempts: number;\n}\n\nexport interface OfflineQueueConfig {\n /** Hard cap on items per queue (api + sync are tracked separately). Default 200. */\n maxSize: number;\n /** Drop items older than this many ms when flushing/enqueuing. Default 1 hour. */\n maxAgeMs: number;\n /**\n * What to do when the queue is full:\n * - 'drop-oldest' (default): evict the oldest item, append the new one.\n * - 'drop-newest': reject the new item, keep the existing queue.\n * - 'reject': do not enqueue; caller must handle the failure.\n */\n dropPolicy: 'drop-oldest' | 'drop-newest' | 'reject';\n}\n\nexport interface SyncStreamThrottleConfig {\n /** Default `flushAtChars` for `createStreamThrottle({...})`. */\n flushAtChars: number;\n /** Default `flushEveryMs` for `createStreamThrottle({...})`. */\n flushEveryMs: number | false;\n /** Default `field` (payload key) for emitted chunks. */\n field: string;\n}\n\nexport interface TransportConfig {\n /**\n * Transport used for typed API/sync invocations. Socket.io remains connected\n * in both modes for rooms, presence, reconnect state and realtime callbacks.\n * `'socket'` is the backwards-compatible monolith default; `'routed-http'`\n * sends invocations over same-origin HTTP/SSE so @luckystack/router can select\n * the service owner.\n */\n invocation: 'socket' | 'routed-http';\n}\n\nexport interface ApiConfig {\n /**\n * Default response timeout (ms) for `apiRequest`. After this elapses with no\n * response (e.g. server restart/crash between emit and reply) the request\n * settles with `{ status:'error', errorCode:'api.timeout', httpStatus:504 }`\n * instead of hanging forever. Set `false` to disable the timeout. A per-call\n * `timeoutMs` overrides this. Default 30000.\n */\n requestTimeoutMs: number | false;\n}\n\nexport interface ValidationConfig {\n /**\n * Whether runtime input validation runs in PRODUCTION (CORE-01).\n *\n * - `'enforce'` (DEFAULT) — the structural validator (`validateType`) runs\n * against the route's resolved input type in production too, so a malformed\n * payload is rejected with `api.invalidInputType` / `sync.invalidInputType`\n * instead of reaching the handler. Only the dev-only devkit DEEP type\n * resolver (TypeScript compiler API) is skipped in prod — the already-resolved\n * generated type text is validated directly.\n * - `'off'` — restore the legacy behavior where prod skips input validation\n * entirely (input shape is the handler's responsibility). This is the loud,\n * documented opt-out; set it only if a route's generated type text can't be\n * validated structurally in your deployment.\n *\n * DEFAULT `'enforce'`. Note: this CHANGES prior behavior (prod was a no-op).\n * Set `'off'` to keep the old no-op.\n */\n runtimeMode: 'enforce' | 'off';\n}\n\nexport interface SyncConfig {\n streamThrottle: SyncStreamThrottleConfig;\n /**\n * Default response timeout (ms) for `syncRequest`'s acknowledgement. Same\n * semantics as `api.requestTimeoutMs` but for sync. Settles with\n * `sync.requestTimeout` / httpStatus 504 on expiry. `false` to disable.\n * Default 30000.\n */\n requestTimeoutMs: number | false;\n /**\n * Yield to the event loop every N recipients during a broadcast fanout\n * (`receiver: 'all'` or large rooms). Lower = more responsive, higher\n * overhead. Default 100.\n */\n fanoutYieldEvery: number;\n /** Milliseconds to sleep when yielding. Default 1ms. */\n fanoutYieldMs: number;\n /**\n * Receiver-authorization policy (SYNC-07). These flags add framework-level\n * defaults read by `@luckystack/sync`'s `authorizeSyncReceiver`:\n *\n * - `allowClientReceiverAll` — when false, a client requesting the broadcast\n * receiver `'all'` is rejected unless a `preSyncAuthorize` handler approves\n * it. DEFAULT `false` (0.2.0 secure-default flip — a client can no longer\n * broadcast cluster-wide by default; opt back in with `true` for the legacy\n * permissive behavior).\n * - `requireRoomMembership` — when true, a client may only target a room it\n * has actually joined (its `roomCodes`); targeting an unjoined room is\n * rejected. DEFAULT `true` (0.2.0 secure-default flip — a client can no\n * longer fan out to a room it never joined; set `false` for the legacy\n * any-room behavior).\n *\n * BREAKING (0.2.0): both defaults now fail CLOSED. Apps that relied on\n * implicit cluster-wide / arbitrary-room broadcasts must either join the room\n * before targeting it, approve the receiver via a `preSyncAuthorize` handler,\n * or explicitly opt back into the permissive values.\n */\n allowClientReceiverAll: boolean;\n requireRoomMembership: boolean;\n /**\n * Stream backpressure tuning (SYNC-15) for `createStreamThrottle` /\n * server-initiated stream emits. Constants were previously hardcoded in the\n * sync package; surfacing them here lets a consumer tune flush cadence under\n * load without forking. DEFAULTS reproduce the historical hardcoded values.\n */\n flushPressure: SyncFlushPressureConfig;\n}\n\nexport interface SyncFlushPressureConfig {\n /** Max queued chunks before the stream applies backpressure (pauses). Default 1000. */\n highWaterMarkChunks: number;\n /** Resume emitting once the queue drains below this. Default 250. */\n lowWaterMarkChunks: number;\n /** Hard cap on bytes buffered for a single stream before dropping/erroring. Default 5_242_880 (5 MiB). */\n maxBufferedBytes: number;\n}\n\nexport interface SocketConfig {\n /**\n * Maximum payload size (bytes) for any single Socket.io message (whole-frame\n * buffer). DEFAULT 5 MiB (5 * 1024 * 1024). This is the SOCKET-transport cap; the\n * HTTP transport has a SEPARATE, independently-configurable body cap\n * (`http.requestBodyMaxBytes`, default 1 MiB). The two are intentionally distinct —\n * set both to the same value for a uniform per-call ceiling across transports.\n */\n maxHttpBufferSize: number;\n /** ms with no pong response before the server considers the client gone. */\n pingTimeout: number;\n /** ms between pings. */\n pingInterval: number;\n /**\n * Minimum ms between client → server `activity` heartbeats (repo-src C-CFG /\n * presence C1). The consumer template's activity tracker + `@luckystack/presence`\n * read this to throttle how often mouse/keyboard/touch activity is reported\n * (previously hardcoded to 10s). DEFAULT 10000 (10s) — a missing key keeps the\n * historical cadence.\n */\n activityHeartbeatThrottleMs: number;\n /**\n * Hard cap on how many rooms a single session may be joined to at once. When a\n * join would exceed the cap the OLDEST joined room is left first (FIFO eviction)\n * so `session.roomCodes` can't grow unbounded in Redis (session-bloat DoS).\n * DEFAULT 50. Set to `false` to disable the cap (unbounded — legacy behavior).\n */\n maxRoomsPerSession: number | false;\n}\n\nexport interface DevConfig {\n /** Debounce window for hot-reload triggers. */\n hotReloadDebounceMs: number;\n /** Chokidar `awaitWriteFinish.stabilityThreshold`. */\n watcherStabilityThresholdMs: number;\n /** Chokidar `awaitWriteFinish.pollInterval`. */\n watcherPollIntervalMs: number;\n /**\n * When true, log a warning the first time an api/sync route is invoked\n * without an `inputType` (typically because generated types haven't been\n * regenerated since the route was added). Helps catch routes that ship\n * with no runtime input validation. Default false (silent), set to true\n * in dev to surface missing types.\n */\n warnOnMissingInputType?: boolean;\n}\n\nexport interface PathsConfig {\n /** Frontend source root. */\n srcDir: string;\n /** Server-side source root. */\n serverDir: string;\n /** Shared (universal) source root. */\n sharedDir: string;\n /** Where uploaded user assets (avatars, etc.) live. */\n uploadsDir: string;\n /** Public static assets served as-is. */\n publicDir: string;\n /**\n * @deprecated Use `serverFunctionDirs` instead. Kept for backwards\n * compatibility with projects that set the singular form before the\n * function-injection system grew multi-directory support. When both are\n * present, `serverFunctionDirs` wins.\n */\n serverFunctionsDir: string;\n /**\n * Server-side function modules consumed by API/sync handlers. The\n * codegen and runtime walk every listed directory in order and merge the\n * results into one `Functions` interface / object. Duplicate keys\n * (same `<dirname>.<filename>.<export>` path produced by two roots)\n * fail the build — `shared/` is canonical, so the conflict tells the\n * consumer to delete the override.\n *\n * Default: `['functions', 'shared']`.\n *\n * Nested subdirectories work transparently — `functions/test/helper.ts`\n * with `export const foo = …` shows up as\n * `functions.test.helper.foo` on the injected `functions` parameter.\n */\n serverFunctionDirs: string[];\n /** Generated socket types output path (relative to project root). */\n generatedSocketTypes: string;\n /** Generated API input schemas output path. */\n generatedApiSchemas: string;\n /** Generated API docs JSON output path. */\n generatedApiDocs: string;\n}\n\nexport interface ProjectConfig {\n app: AppConfig;\n logging: LoggingConfig;\n rateLimiting: RateLimitingConfig;\n session: SessionConfig;\n http: HttpConfig;\n auth: AuthConfig;\n socket: SocketConfig;\n transport: TransportConfig;\n api: ApiConfig;\n sync: SyncConfig;\n validation: ValidationConfig;\n offlineQueue: OfflineQueueConfig;\n dev: DevConfig;\n paths: PathsConfig;\n defaultLanguage: string;\n /** Default theme for new users / fallback when no session preference exists. */\n defaultTheme?: 'light' | 'dark';\n /** Enable per-room activity broadcasting (presence). */\n socketActivityBroadcaster?: boolean;\n /** Show the floating socket-status indicator badge from `@luckystack/presence/client`. */\n socketStatusIndicator?: boolean;\n /** Enable client → server `updateLocation` syncing. */\n locationProviderEnabled?: boolean;\n /** Where to redirect the user after a successful OAuth callback. */\n loginRedirectUrl?: string;\n /**\n * Base origin used to build OAuth callback redirect URIs\n * (`<oauthCallbackBase>/auth/callback/<provider>`). This is the BACKEND\n * origin the provider redirects to — in dev typically `http://localhost:80`,\n * in prod the public domain. Read by `@luckystack/login/register`'s env-driven\n * provider scan so OAuth wiring needs no consumer code. Empty default — the\n * consumer's `config.ts` sets it.\n */\n oauthCallbackBase?: string;\n}\n\nexport type ProjectConfigInput = DeepPartial<ProjectConfig>;\n\nexport const DEFAULT_PROJECT_CONFIG: ProjectConfig = {\n app: {\n publicUrl: '',\n },\n logging: {\n devLogs: false,\n devNotifications: false,\n socketStatus: false,\n socketStartup: false,\n stream: false,\n timestamps: true,\n },\n rateLimiting: {\n enabled: true,\n store: 'memory',\n redisKeyPrefix: 'rate-limit',\n defaultApiLimit: 60,\n defaultIpLimit: 100,\n windowMs: 60_000,\n cleanupIntervalMs: 60_000,\n onStoreError: 'memory',\n skipLoopbackInDev: false,\n //? DD-CORE-D3: consumed exclusively by @luckystack/login, not by core itself.\n auth: {\n enabled: false,\n maxAttempts: 5,\n maxAttemptsPerAccount: 50,\n windowMs: 15 * 60 * 1000,\n },\n },\n session: {\n basedToken: false,\n expiryDays: 7,\n perUser: 'single',\n maxConcurrentPerUser: null,\n onConflict: 'revokeOld',\n notifyOldDeviceOnRevoke: true,\n //? Empty default — `getProjectName()` reads `process.env.PROJECT_NAME` at\n //? call time and falls back to `'luckystack'`. Avoids capturing env at\n //? module-load before dotenv runs.\n projectName: '',\n },\n http: {\n sessionCookieName: 'token',\n sessionCookieSameSite: 'Strict',\n sessionCookiePath: '/',\n requestBodyMaxBytes: 1024 * 1024,\n healthEndpoint: '/_health',\n liveEndpoint: '/livez',\n readyEndpoint: '/readyz',\n testResetEndpoint: '/_test/reset',\n trustProxy: false,\n //? CORE-O3: skip 1 hop from the RIGHT of X-Forwarded-For (the rightmost\n //? entry is appended by your own trusted proxy) instead of trusting the\n //? leftmost, client-controlled hop. Only consulted when `trustProxy` is on.\n trustedProxyHopCount: 1,\n //? CORE-O10 secure default: in cookie-mode, ignore any `Authorization: Bearer`\n //? / `handshake.auth.token` fallback so a stolen token cannot bypass the\n //? cookie/CSRF model. Set true to restore the legacy cookie-then-bearer\n //? fallback. Token-mode (`basedToken: true`) is unaffected.\n acceptBearerInCookieMode: false,\n //? 0.2.0 secure-default flip (SEC-13): `/_health` no longer exposes a stable,\n //? unsalted `sha256(secret)` fingerprint. Default mode is `'hmac'` keyed on\n //? the `'@bootUuid'` sentinel — the per-boot UUID both server + router already\n //? share via the boot handshake — so the synchronized-env hash rotates every\n //? restart and is no longer offline dictionary-attackable. Set an explicit\n //? non-empty `salt` to pin a stable HMAC key across restarts, or `mode:'plain'`\n //? to restore the legacy unsalted wire output.\n healthHash: {\n mode: 'hmac',\n salt: '@bootUuid',\n },\n stream: {\n queryParam: 'stream',\n enabledValue: 'true',\n connectedComment: ': connected',\n },\n securityHeaders: {\n frameOptions: 'SAMEORIGIN',\n referrerPolicy: 'no-referrer',\n //? '1; mode=block' is deprecated (Chrome 78+ ignores it; can trigger\n //? reflected-XSS in older IE/Edge auditors). '0' disables the legacy\n //? auditor and defers to CSP, which is the modern defence.\n xssProtection: '0',\n contentTypeOptions: 'nosniff',\n },\n cors: {\n allowedMethods: 'GET, POST, PUT, DELETE, OPTIONS',\n allowedHeaders: 'Content-Type, Authorization, X-Session-Based-Token, X-CSRF-Token, X-Request-Id',\n exposedHeaders: 'X-Session-Token, X-Request-Id',\n credentials: true,\n allowedOrigins: [],\n allowLocalhost: false,\n allowOriginless: false,\n },\n },\n auth: {\n credentials: true,\n oauthStateTtlSeconds: 60 * 10,\n passwordMinLength: 8,\n passwordMaxLength: 191,\n passwordPolicy: {\n minLength: 8,\n maxLength: 191,\n requireUppercase: false,\n requireLowercase: false,\n requireNumber: false,\n requireSpecial: false,\n forbidCommon: true,\n },\n emailMaxLength: 191,\n nameMaxLength: 191,\n bcryptRounds: 10,\n providerAccountStrategy: 'per-provider',\n forgotPassword: 'disabled',\n passwordResetTtlSeconds: 60 * 60,\n emailChangeTtlSeconds: 60 * 60,\n allowRegistration: true,\n passwordResetPath: '/reset-password',\n emailChangeConfirmPath: '/confirm-email-change',\n emailCodeLogin: false,\n emailCodeTtlSeconds: 60 * 10,\n emailCodeLength: 6,\n emailCodeMaxAttempts: 5,\n twoFactor: 'disabled',\n twoFactorEmailFallback: true,\n twoFactorChallengeTtlSeconds: 60 * 5,\n twoFactorMaxAttempts: 5,\n },\n socket: {\n maxHttpBufferSize: 5 * 1024 * 1024,\n pingTimeout: 20_000,\n pingInterval: 25_000,\n activityHeartbeatThrottleMs: 10_000,\n maxRoomsPerSession: 50,\n },\n transport: {\n invocation: 'socket',\n },\n api: {\n //? Default OFF (was 30_000): a legit handler may run for minutes (e.g. a deep\n //? image parse / long upstream call) and must not be aborted by a wall-clock\n //? race. A disconnecting client still aborts the handler via the wired\n //? abortSignal; set a number to opt back into a hard per-request ceiling\n //? (streaming requests are exempt even then — see runHttpApiExecution).\n requestTimeoutMs: false,\n },\n validation: {\n runtimeMode: 'enforce',\n },\n sync: {\n streamThrottle: {\n flushAtChars: 32,\n flushEveryMs: 50,\n field: 'chunk',\n },\n fanoutYieldEvery: 100,\n fanoutYieldMs: 1,\n requestTimeoutMs: 30_000,\n allowClientReceiverAll: false,\n requireRoomMembership: true,\n flushPressure: {\n highWaterMarkChunks: 1000,\n lowWaterMarkChunks: 250,\n maxBufferedBytes: 5 * 1024 * 1024,\n },\n },\n offlineQueue: {\n maxSize: 200,\n maxAgeMs: 60 * 60 * 1000,\n dropPolicy: 'drop-oldest',\n },\n dev: {\n hotReloadDebounceMs: 120,\n watcherStabilityThresholdMs: 120,\n watcherPollIntervalMs: 20,\n },\n paths: {\n srcDir: 'src',\n serverDir: 'server',\n sharedDir: 'shared',\n uploadsDir: 'uploads',\n publicDir: 'public',\n serverFunctionsDir: 'server/functions',\n serverFunctionDirs: ['functions', 'shared'],\n generatedSocketTypes: 'src/_sockets/apiTypes.generated.ts',\n generatedApiSchemas: 'src/_sockets/apiInputSchemas.generated.ts',\n generatedApiDocs: 'src/docs/apiDocs.generated.json',\n },\n defaultLanguage: 'en',\n defaultTheme: 'light',\n socketActivityBroadcaster: false,\n socketStatusIndicator: false,\n locationProviderEnabled: false,\n loginRedirectUrl: '/',\n oauthCallbackBase: '',\n};\n\nconst registry = createRegistry<ProjectConfig, ProjectConfigInput>(DEFAULT_PROJECT_CONFIG, {\n //? Always merge the consumer override over the pristine defaults (not the\n //? previously-stored config) — matches the historical register behaviour\n //? where each call rebuilds from `DEFAULT_PROJECT_CONFIG`.\n transform: (input) => deepMerge(DEFAULT_PROJECT_CONFIG, input),\n});\n\nexport const registerProjectConfig = (config: ProjectConfigInput): void => {\n registry.register(config);\n};\n\nexport const getProjectConfig = (): ProjectConfig => registry.get();\n\n//? Resolve the project namespace at call time. Single source of truth used\n//? for Redis key prefixes (`<projectName>-session:`, `-activeUsers:`,\n//? `-pwreset:`, `-oauth-state:`, etc.) across session.ts, logout.ts,\n//? rateLimiter.ts, passwordReset.ts, login.ts, testResetRoute.ts.\n//?\n//? Resolution order (first non-empty wins):\n//? 1. `projectConfig.session.projectName` (if a consumer set it explicitly)\n//? 2. `process.env.PROJECT_NAME` (read at call time — works after dotenv)\n//? 3. literal `'luckystack'` as the absolute fallback\nexport const getProjectName = (): string => {\n const fromConfig = registry.get().session.projectName;\n if (fromConfig && fromConfig.length > 0) return fromConfig;\n const fromEnv = process.env.PROJECT_NAME;\n if (fromEnv && fromEnv.length > 0) return fromEnv;\n return 'luckystack';\n};\n\n//? Guard for code paths that MUST run after registration (e.g. the server's\n//? startup sequence). Log once, never throw — framework packages should\n//? still do something reasonable when called in a test or CLI context that\n//? never registered.\nexport const isProjectConfigRegistered = (): boolean => registry.isRegistered();\n"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|