@luckystack/core 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,243 +1,245 @@
1
- # @luckystack/core
2
-
3
- > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
-
5
- ## What this package does
6
-
7
- Foundation package for LuckyStack. Owns the socket-first transport contracts (`apiMessage`, `syncMessage`, io-instance slot), the registries that every other framework package reads from (project config, deploy/services config, runtime maps, prisma/redis clients, notifier, email senders, error trackers, logger), and the cross-cutting primitives (rate limiter, CORS check, validateRequest, hook bus, response normalizer, offline queue, CSRF, cookies). Every other `@luckystack/*` package depends on this one and never reaches around it.
8
-
9
- ## When to USE
10
-
11
- - Reading the active runtime config (`getProjectConfig()`, `getProjectName()`, `getBindAddress()`).
12
- - Registering DI slots from the project's boot entry (`registerProjectConfig`, `registerPrismaClient`, `registerRedisClient`, `registerLogger`, `registerNotifier`, `registerEmailSender(s)`, `registerErrorTracker(s)`, `registerRuntimeMapsProvider`, `registerApiMethodMap`, `registerAvatarConfig`, `registerBindAddress`, `registerRateLimitStrategy`).
13
- - Subscribing to lifecycle/transport events via `registerHook(...)` / `registerSyncHook(...)`.
14
- - Calling the cross-cutting primitives directly when a feature package would be overkill (`tryCatch`, `notify`, `checkRateLimit`, `allowedOrigin`, `validateRequest`, `extractTokenFromSocket`, `extractTokenFromRequest`, the offline queue API).
15
- - Reading shared transport types (`BaseSessionLayout`, `AuthProps`, `apiMessage`, `syncMessage`, `SOCKETSTATUS`).
16
-
17
- ## When NOT to suggest yet
18
-
19
- - Building a feature endpoint: import `@luckystack/api` / `@luckystack/sync` instead — they wrap core's hooks and validation in the file-based routing contract.
20
- - Sending email: use `@luckystack/email`'s `sendEmail(...)` (it routes through `getEmailSenderByName`).
21
- - Booting an HTTP server: use `createLuckyStackServer` from `@luckystack/server` — it wires `registerBindAddress`, `setIoInstance`, `attachSocketRedisAdapter`, security headers, etc. for you.
22
- - Replacing Sentry: register an adapter via `@luckystack/error-tracking` (which calls `registerErrorTracker(s)` here). Do not call `initSharedSentry` in new code.
23
- - Anything React-coupled: import from `@luckystack/core/client` (browser-safe subpath) or from the consuming feature package's `/client` entry. The server barrel intentionally does NOT export `apiRequest`.
24
- - Defining project session types: extend `BaseSessionLayout` in your project's own `config.ts` — do not edit the core type.
25
-
26
- ## Function Index
27
-
28
- | Function / Export | 1-line | Deep doc |
29
- | --- | --- | --- |
30
- | `registerProjectConfig(config: ProjectConfigInput): void` | Deep-merge installer config over `DEFAULT_PROJECT_CONFIG`; call once at boot before any framework code runs. | -> docs/config-registry.md |
31
- | `getProjectConfig(): ProjectConfig` | Read the active config at call time (never at module load). | -> docs/config-registry.md |
32
- | `getProjectName(): string` | Resolve the Redis-prefix project name (config -> `PROJECT_NAME` env -> `'luckystack'`). | -> docs/config-registry.md |
33
- | `isProjectConfigRegistered(): boolean` | Boot-time guard for code paths that must run after registration. | -> docs/config-registry.md |
34
- | `DEFAULT_PROJECT_CONFIG: ProjectConfig` | Built-in defaults exported so consumers can spread + override structurally. | -> docs/config-registry.md |
35
- | `registerDeployConfig(config: DeployConfigShape): DeployConfigShape` | Register the project's deploy topology (resources, environments, routing, development). | -> docs/config-registry.md |
36
- | `getDeployConfig(): DeployConfigShape` | Read deploy topology (defaults to `{ resources: {} }`). | -> docs/config-registry.md |
37
- | `isDeployConfigRegistered(): boolean` | Guard for router/boot probes that need a registered deploy config. | -> docs/config-registry.md |
38
- | `registerServicesConfig(config: ServicesConfigShape): ServicesConfigShape` | Register services + presets topology (consumed by router/presetLoader). | -> docs/config-registry.md |
39
- | `getServicesConfig(): ServicesConfigShape` | Throws when no services config has been registered. | -> docs/config-registry.md |
40
- | `isServicesConfigRegistered(): boolean` | Non-throwing guard. | -> docs/config-registry.md |
41
- | `registerAvatarConfig(config: AvatarConfigInput): void` | Override avatar disk format(s) + Cache-Control header. | -> docs/config-registry.md |
42
- | `getAvatarConfig(): AvatarConfig` | Read active avatar config. | -> docs/config-registry.md |
43
- | `DEFAULT_AVATAR_CONFIG: AvatarConfig` | Default formats `[{ extension: 'webp', contentType: 'image/webp' }]` + 24h cache. | -> docs/config-registry.md |
44
- | `registerBindAddress(address: { ip: string; port: number }): void` | `createLuckyStackServer` writes the resolved listen address here at boot. | -> docs/app-bootstrap.md |
45
- | `getBindAddress(): { ip: string; port: string }` | Resolve at call time (registry -> `SERVER_IP`/`SERVER_PORT` -> `'127.0.0.1'`/`''`). | -> docs/app-bootstrap.md |
46
- | `registerRuntimeMapsProvider(provider: RuntimeMapsProvider): void` | DI slot for generated api/sync maps; called by project `server/prod/runtimeMaps.ts`. | -> docs/app-bootstrap.md |
47
- | `getRuntimeApiMaps(): Promise<RuntimeApiMapsResult>` | Async accessor for `{ apisObject, functionsObject }`. | -> docs/app-bootstrap.md |
48
- | `getRuntimeSyncMaps(): Promise<RuntimeSyncMapsResult>` | Async accessor for `{ syncObject, functionsObject }`. | -> docs/app-bootstrap.md |
49
- | `isRuntimeMapsProviderRegistered(): boolean` | Boot-fail-mode detector for `verifyBootstrap`. | -> docs/app-bootstrap.md |
50
- | `registerApiMethodMap(map: ApiMethodMap): void` | Register generated `apiMethodMap` so `apiRequest` can look up real HTTP methods. | -> docs/app-bootstrap.md |
51
- | `getRegisteredApiMethod(pagePath: string, apiName: string, version: string): HttpMethodLiteral \| undefined` | Lookup helper used by `isGetMethod`. | -> docs/app-bootstrap.md |
52
- | `isApiMethodMapRegistered(): boolean` | Detect whether the prefix-heuristic fallback is active. | -> docs/app-bootstrap.md |
53
- | `registerPrismaClient(client: PrismaClient, key?: string): PrismaClient` | Register a Prisma client into a slot (default `'default'`). Pass a `key` for graded credentials (e.g. `'ro'`/`'rw'`) or per-tenant clients. | -> docs/app-bootstrap.md |
54
- | `registerRedisClient(client: RedisClient, key?: string): RedisClient` | Register an ioredis client into a slot (default `'default'`). | -> docs/app-bootstrap.md |
55
- | `getPrismaClient(): PrismaClient` | Read the `'default'` slot (registered client or lazy default resolver). | -> docs/app-bootstrap.md |
56
- | `getRedisClient(): RedisClient` | Read the `'default'` slot (registered client or lazy default resolver). | -> docs/app-bootstrap.md |
57
- | `getPrismaClientFor(key?: string): PrismaClient` | Read a specific slot. `'default'` falls back to the resolver; any other unregistered slot throws (never silently returns the privileged default). | -> docs/app-bootstrap.md |
58
- | `getRedisClientFor(key?: string): RedisClient` | Read a specific Redis slot (same semantics as `getPrismaClientFor`). | -> docs/app-bootstrap.md |
59
- | `getPrismaClientKeys(): string[]` / `getRedisClientKeys(): string[]` | Diagnostic — which slots have an explicitly-registered client. | -> docs/app-bootstrap.md |
60
- | `DEFAULT_CLIENT_KEY: 'default'` | The reserved slot backing every framework internal + the `prisma`/`redis` proxies. | -> docs/app-bootstrap.md |
61
- | `isPrismaClientRegistered(): boolean` | Boot guard — true when the `'default'` slot is registered. | -> docs/app-bootstrap.md |
62
- | `isRedisClientRegistered(): boolean` | Boot guard — true when the `'default'` slot is registered. | -> docs/app-bootstrap.md |
63
- | `resetClientsForTests(): void` | Test-only — drop every registered slot (default resolvers stay set). | -> docs/app-bootstrap.md |
64
- | `redis` | Proxy that forwards every call to the currently registered ioredis client. Applies `applyStrayKeyPrefix` to single-key commands as a best-effort namespace net (keys containing `:` pass through untouched). | -> docs/redis-adapter.md |
65
- | `getRedisConnectionOptions(): RedisConnectionOptions` | Single source of truth for `{ host, port, username?, password? }` (router reuses for cross-env probes). | -> docs/redis-adapter.md |
66
- | `formatKey(namespace: string, suffix?: string): string` | Build a namespaced Redis key through the active formatter. The single authority every framework key-site routes through (`-session`, `-activeUsers`, `-pwreset`, `-email-change`, `-oauth-state`, `:rate-limit`). Default reproduces historical key bytes (zero migration). | -> docs/redis-adapter.md |
67
- | `registerRedisKeyFormatter(fn: RedisKeyFormatter): void` | Override how every framework Redis key is namespaced (multi-tenant per-tenant prefixing). Must preserve the `<namespace-root>:<suffix>` join so `SCAN` enumeration still works. | -> docs/redis-adapter.md |
68
- | `getRedisKeyFormatter(): RedisKeyFormatter` / `defaultRedisKeyFormatter` / `resetRedisKeyFormatterForTests()` | Read the active formatter / the built-in default / test reset. | -> docs/redis-adapter.md |
69
- | `applyStrayKeyPrefix(key: string): string` | Project-prefix an un-namespaced (colon-free) key; pass-through for already-namespaced keys. Used by the `redis` proxy net. | -> docs/redis-adapter.md |
70
- | `acquireLease(name, ttlMs): Promise<string \| null>` | Acquire an exclusive Redis lease (`SET NX PX`). Returns an owner token, or null if held. Single-Redis best-effort (not Redlock) — the lease is a primitive; the renew loop is app code. | -> docs/redis-adapter.md |
71
- | `renewLease(name, token, ttlMs): Promise<boolean>` | Owner-checked compare-and-pexpire (Lua); extends only if `token` still holds the lease. | -> docs/redis-adapter.md |
72
- | `releaseLease(name, token): Promise<boolean>` | Owner-checked compare-and-delete (Lua); releasing another owner's lease is a no-op. | -> docs/redis-adapter.md |
73
- | `attachSocketRedisAdapter(io: SocketIOServer): void` | Wire `@socket.io/redis-adapter` with duplicated pub/sub clients so room broadcasts span instances. | -> docs/redis-adapter.md |
74
- | `setIoInstance(io: SocketIOServer \| null): void` | Module-level slot for the running Socket.io server. | -> docs/socket-bootstrap.md |
75
- | `getIoInstance(): SocketIOServer \| null` | Read the slot from framework code that needs to broadcast. | -> docs/socket-bootstrap.md |
76
- | `socket`, `setSocket`, `incrementResponseIndex`, `waitForSocket` | Client-side socket singleton + response-index counter. | -> docs/socket-bootstrap.md |
77
- | `extractTokenFromSocket(socket): string \| null` | Read session token from Socket.io handshake. Token-mode reads `handshake.auth.token` then cookie; cookie-mode reads ONLY the cookie unless `http.acceptBearerInCookieMode` is true (CORE-O10). | -> docs/socket-bootstrap.md |
78
- | `extractTokenFromRequest(request): string \| null` | Read session token from a Node IncomingMessage. Token-mode reads `Authorization: Bearer` then cookie; cookie-mode reads ONLY the cookie unless `http.acceptBearerInCookieMode` is true (CORE-O10). | -> docs/socket-bootstrap.md |
79
- | `allowedOrigin(origin: string): boolean` | Same-origin + project-configured allow-list CORS check; dispatches `corsRejected` hook on miss. | -> docs/socket-bootstrap.md |
80
- | `validateRequest({ auth, user }): ValidationResult` | Evaluates the `auth.additional[]` predicates against the session; returns success immediately when `additional` is absent. Does NOT check `auth.login` — login is enforced by the surrounding API/sync handler, not by this function. | -> docs/session-types.md |
81
- | `isFalsy(value): boolean` | Helper used inside `validateRequest`. | -> docs/session-types.md |
82
- | `validateInputByType(value, type)` | Runtime input validation; lazy-loads `@luckystack/devkit` in dev. | -> docs/session-types.md |
83
- | `registerNotifier(notifier: Notifier): void` | DI for client-side toast notifier (success/error/info/warning). | -> docs/app-bootstrap.md |
84
- | `getNotifier(): Notifier` | Read active notifier (no-op default). | -> docs/app-bootstrap.md |
85
- | `notify: Notifier` | Delegating wrapper used by framework hot paths. | -> docs/app-bootstrap.md |
86
- | `registerEmailSender(sender: EmailSender): void` | Legacy single-sender registration; mirrors into `default` slot. | -> docs/app-bootstrap.md |
87
- | `registerEmailSenders(senders: EmailSenderRegistry): void` | Multi-adapter registration (`'default'`, `'transactional'`, `'marketing'`, custom). | -> docs/app-bootstrap.md |
88
- | `getEmailSender(): EmailSender \| null` | Read legacy/`default` sender. | -> docs/app-bootstrap.md |
89
- | `getEmailSenderByName(name: string): EmailSender \| null` | Read a specific slot (falls back to legacy sender for `'default'`). | -> docs/app-bootstrap.md |
90
- | `listEmailSenderNames(): string[]` | Diagnostic helper. | -> docs/app-bootstrap.md |
91
- | `isEmailSenderRegistered(): boolean` | Boot guard. | -> docs/app-bootstrap.md |
92
- | `registerLogger(logger: Logger): void` | Plug in Pino/Winston/Datadog (or `createDevLogger()` for colored dev output). | -> docs/app-bootstrap.md |
93
- | `getLogger(): Logger` | Read at call time (default = console.{debug,info,warn,error}). | -> docs/app-bootstrap.md |
94
- | `isLoggerRegistered(): boolean` | Boot guard. | -> docs/app-bootstrap.md |
95
- | `createDevLogger(): Logger` | Factory for the dev-mode colored logger (depends on `initConsolelog()`). | -> docs/app-bootstrap.md |
96
- | `resetLoggerForTests(): void` | Test-only — restore default logger. | -> docs/app-bootstrap.md |
97
- | `registerRedactedLogKeys(keys: Iterable<string>): void` | Add keys that the framework will redact from log payloads. | -> docs/app-bootstrap.md |
98
- | `getRedactedLogKeys(): Set<string>` | Read current redacted-keys set. | -> docs/app-bootstrap.md |
99
- | `isRedactedLogKey(key: string): boolean` | Hot-path lookup used inside the framework. Matches the exact registered set AND any key whose lowercased form ENDS WITH a sensitive suffix (`token`/`secret`/`apikey`/`password`) — so `targetToken` / `clientSecret` / `stripeApiKey` redact without being registered, while `tokenCount` / `secretSanta` stay untouched. | -> docs/app-bootstrap.md |
100
- | `resetRedactedLogKeysForTests(): void` | Test-only reset. | -> docs/app-bootstrap.md |
101
- | `sanitizeForLog(value): unknown` | Recursive redaction pass — deep-clones `value`, replacing any redacted-key field with `REDACTED_PLACEHOLDER`. Applied on the `captureException`/`captureMessage` fan-out so a raw token nested in context never reaches an adapter (SYNC-17). | -> docs/app-bootstrap.md |
102
- | `DEFAULT_REDACTED_LOG_KEYS: readonly string[]` | The built-in masked-key set (token, password, authorization, cookie, csrfToken, apiKey, secret, …) seeded into the redacted-keys registry. Widened in 0.2.0 (added `csrftoken`/`apikey`/`secret`) plus suffix matching in `isRedactedLogKey`. | -> docs/app-bootstrap.md |
103
- | `REDACTED_PLACEHOLDER: string` | The constant `sanitizeForLog` substitutes for a redacted value. | -> docs/app-bootstrap.md |
104
- | `initConsolelog(): void` | Monkey-patch `console.*` to render trailing color string (dev only). | -> docs/app-bootstrap.md |
105
- | `registerLocaleReloader(reloader: LocaleReloader): void` | DI for the dev-only i18n hot-reload trigger. | -> docs/app-bootstrap.md |
106
- | `getLocaleReloader(): LocaleReloader \| null` | Read active reloader (returns null when no project supplied one). | -> docs/app-bootstrap.md |
107
- | `registerErrorTracker(tracker: ErrorTracker): void` | Single-tracker registration (replaces previous). | -> docs/error-tracker-registry.md |
108
- | `registerErrorTrackers(trackers: ErrorTracker[]): void` | Multi-tracker registration (replaces the list). | -> docs/error-tracker-registry.md |
109
- | `appendErrorTracker(tracker: ErrorTracker): void` | Accumulate-not-replace registration — appends a tracker, deduping by `ErrorTracker.name`. Use for async auto-registration (e.g. PostHog) so it can't clobber a consumer overlay. | -> docs/error-tracker-registry.md |
110
- | `runWithErrorTrackerIdentity(user, fn): T` / `getCurrentErrorTrackerIdentity(): ErrorTrackerUser \| null` | AsyncLocalStorage per-event identity — wrap request handling in `runWithErrorTrackerIdentity(user, fn)` and capture sites read the current identity instead of a mutable global (no cross-request bleed). | -> docs/error-tracker-registry.md |
111
- | `registerPreCaptureFilter(filter: PreCaptureFilter): void` | Register a filter run before every capture fan-out (drop/transform events centrally). | -> docs/error-tracker-registry.md |
112
- | `startSpanHandle(name, op): SpanHandle` | Handle-style span — returns a `{ finish() }` handle (vs the callback-style `startSpanAcrossTrackers`). | -> docs/error-tracker-registry.md |
113
- | `flushErrorTrackers(): Promise<void>` | Flush lifecycle — calls every adapter's optional `flush?()`; call on shutdown so buffered events aren't lost. | -> docs/error-tracker-registry.md |
114
- | `getActiveErrorTrackers(): ErrorTracker[]` | Read active trackers. | -> docs/error-tracker-registry.md |
115
- | `captureExceptionAcrossTrackers(error, context?): void` | Fan-out exception capture; per-tracker errors are swallowed. | -> docs/error-tracker-registry.md |
116
- | `captureMessageAcrossTrackers(message, level, context?): void` | Fan-out message capture. | -> docs/error-tracker-registry.md |
117
- | `setErrorTrackerUser(user: ErrorTrackerUser \| null): void` | Fan-out user identification. | -> docs/error-tracker-registry.md |
118
- | `recordMetricAcrossTrackers(name, value, tags?): void` | Optional metric fan-out (skips trackers without `recordMetric`). | -> docs/error-tracker-registry.md |
119
- | `startSpanAcrossTrackers<T>(name, op, fn): T` | First tracker that supports spans wins; falls back to direct `fn()`. | -> docs/error-tracker-registry.md |
120
- | `initSharedSentry(instance)` | Legacy Sentry slot (kept for backwards compatibility). | -> docs/error-tracker-registry.md |
121
- | `captureException(error, context?): void` | Legacy fan-out: Sentry slot + adapter list. | -> docs/error-tracker-registry.md |
122
- | `captureMessage(message, level?, context?): void` | Legacy fan-out: Sentry slot + adapter list. | -> docs/error-tracker-registry.md |
123
- | `setSentryUser(user): void` | Legacy fan-out: Sentry slot + adapter list. | -> docs/error-tracker-registry.md |
124
- | `startSpan(name, op): unknown` | Legacy span helper. | -> docs/error-tracker-registry.md |
125
- | `checkRateLimit(params: CheckRateLimitParams): Promise<RateLimitResult>` | Delegates to the active strategy's `check()`. | -> docs/rate-limit-strategy.md |
126
- | `getRateLimitStatus(key, limit): Promise<RateLimitResult>` | Read-only status (does not increment). | -> docs/rate-limit-strategy.md |
127
- | `clearRateLimit(key): Promise<void>` | Reset a single key. | -> docs/rate-limit-strategy.md |
128
- | `clearAllRateLimits(): Promise<void>` | Reset every key under the active strategy's namespace. | -> docs/rate-limit-strategy.md |
129
- | `registerRateLimitStrategy(strategy: RateLimitStrategy): void` | Swap the backend (token-bucket, edge-KV, per-tier, no-op). | -> docs/rate-limit-strategy.md |
130
- | `getRateLimitStrategy(): RateLimitStrategy` | Read the currently active strategy. | -> docs/rate-limit-strategy.md |
131
- | `defaultRateLimitStrategy: RateLimitStrategy` | Built-in memory-or-redis backend. | -> docs/rate-limit-strategy.md |
132
- | `registerHook<TName>(name, handler): void` | Subscribe an async handler to a lifecycle event. | -> docs/hooks.md |
133
- | `registerMiddlewareHandler(handler: MiddlewareHandler): void` | Register the GLOBAL route-guard fallback. Called by `<Middleware>` / `useRouter` only when no per-page middleware is registered for the visited route. Optional — framework default allows by-default. | -> docs/hooks.md |
134
- | `getMiddlewareHandler(): MiddlewareHandler` | Read the active global handler. | -> docs/hooks.md |
135
- | `registerPageMiddleware(path: string, fn: PageMiddleware): void` | Register a per-page route guard. Auto-called by `src/main.tsx`'s `getRoutes()` for every page that exports `middleware`. Per-page takes precedence over the global handler. | -> docs/hooks.md |
136
- | `getPageMiddleware(path: string): PageMiddleware \| undefined` | Lookup the per-page guard for a given route. Framework-internal. | -> docs/hooks.md |
137
- | `hasPageMiddleware(path: string): boolean` | Existence check. | -> docs/hooks.md |
138
- | `MiddlewareInput`, `MiddlewareResult`, `MiddlewareHandler`, `PageMiddleware` (types) | Route-guard contract types. Both handler shapes share the same signature. | -> docs/hooks.md |
139
- | `validatePagePath(srcRelativePath, rules?): PagePathValidationResult` | Pure validator for the invisible-parent folder convention used by `src/main.tsx`'s page auto-discovery AND the `scaffold:page` CLI. Returns `{ valid, route?, reason? }`. | -> docs/hooks.md |
140
- | `DEFAULT_PAGE_ROUTE_RULES: PageRouteRules` | Default config (private-folder prefix `'_'` + reserved framework folders). | -> docs/hooks.md |
141
- | `dispatchHook<TName>(name, payload): Promise<DispatchResult>` | Internal: framework code invokes registered handlers in order; first stop signal aborts. | -> docs/hooks.md |
142
- | `clearAllHooks(): void` | Test-only — drop every registered handler (sync + async). | -> docs/hooks.md |
143
- | `registerSyncHook<TName>(name, handler): void` | Subscribe a synchronous mutator to a hot-path hook (e.g. error normalization). | -> docs/hooks.md |
144
- | `dispatchSyncHook<TName>(name, payload): void` | Internal: framework code invokes sync handlers; payload is mutated in place. | -> docs/hooks.md |
145
- | `BaseSessionLayout`, `SessionLocation`, `AuthProps` (types) | Foundational session-shape types; project `SessionLayout` extends `BaseSessionLayout`. | -> docs/session-types.md |
146
- | `HookSessionShape`, `HookName`, `HookHandler`, `HookResult`, `HookStopSignal`, `HookPayloads` (types) | Hook contract types (augmentable via TS module augmentation). | -> docs/hooks.md |
147
- | `preServerStop` hook + `PreServerStopPayload` (type) | Graceful-shutdown lifecycle hook — `@luckystack/server` dispatches it once on SIGTERM/SIGINT before the server stops accepting connections. Payload `{ reason, timeoutMs? }`. Best-effort (a stop signal does NOT abort shutdown); use to flush trackers / drain queues / close pools. | -> docs/hooks.md |
148
- | `isOnline()` / `enqueueApiRequest` / `enqueueSyncRequest` / `removeApiQueueItem` / `removeSyncQueueItem` / `removeApiQueueItemsByKey` / `flushApiQueue` / `flushSyncQueue` / `getApiQueueSize` / `getSyncQueueSize` | Client-side offline queue with per-item `dropPolicy` and global max-size/max-age caps. | -> docs/socket-bootstrap.md |
149
- | `getCsrfToken()`, `clearCsrfToken()`, `httpFetch(...)` | CSRF-aware fetch wrapper used by the client transport. | -> docs/socket-bootstrap.md |
150
- | `issueOneTimeToken(namespace, ttlSeconds, payload): OneTimeTokenHandle` | Mint a single-use Redis-backed token (returns `{ token, store() }`). HASHED AT REST — only `sha256(token)` is stored as the key, never the raw token. Used by `@luckystack/login` for password-reset + email-change links. | -> docs/app-bootstrap.md |
151
- | `consumeOneTimeToken(namespace, token): Promise<string \| null>` | Atomically validate + consume a one-time token (single `MULTI` GET+DEL → at-most-once). Returns the stored payload string, or null on miss/expired/reused. | -> docs/app-bootstrap.md |
152
- | `consumeOneTimeTokenJson<T>(namespace, token): Promise<T \| null>` | `consumeOneTimeToken` + JSON-parse; null on miss OR malformed payload. | -> docs/app-bootstrap.md |
153
- | `OneTimeTokenHandle` (type) | `{ token: string; store(): Promise<void> }` returned by `issueOneTimeToken`. | -> docs/app-bootstrap.md |
154
- | `registerCsrfConfig(input: Partial<CsrfConfig>): void` | Override the CSRF cookie name, header name, token length, or cookie options. | -> docs/csrf-config.md |
155
- | `getCsrfConfig(): CsrfConfig` | Read the active CSRF config at call time (defaults to `DEFAULT_CSRF_CONFIG`). | -> docs/csrf-config.md |
156
- | `DEFAULT_CSRF_CONFIG: CsrfConfig` | Built-in defaults (`csrf-token` cookie, `x-csrf-token` header, 32-byte token). | -> docs/csrf-config.md |
157
- | `resetCsrfConfigForTests(): void` | Test-only — restore CSRF defaults between scenarios. | -> docs/csrf-config.md |
158
- | `registerSocketMiddleware(mw: SocketMiddleware): void` | Wedge an `io.use(...)` middleware into the framework's socket bootstrap; runs before any `connect` handler. | -> docs/socket-bootstrap.md |
159
- | `getSocketMiddlewares(): readonly SocketMiddleware[]` | Read the registered middleware list. | -> docs/socket-bootstrap.md |
160
- | `clearSocketMiddlewares(): void` | Drop every registered middleware (test/hot-reload helper). | -> docs/socket-bootstrap.md |
161
- | `applySocketMiddlewares(io: SocketIOServer): void` | Wire every registered middleware into the running Socket.io server (called from `@luckystack/server`'s `loadSocket`). | -> docs/socket-bootstrap.md |
162
- | `writeBootUuid(envKey?): Promise<string>` | Write a fresh UUID to `luckystack:boot:<envKey>` with the configured TTL. | -> docs/app-bootstrap.md |
163
- | `readBootUuid(envKey?): Promise<string \| null>` | Read the boot UUID (router cross-checks against `/_health`). | -> docs/app-bootstrap.md |
164
- | `resolveEnvKey(): string` | `LUCKYSTACK_ENV` -> `NODE_ENV` -> `'development'`. | -> docs/app-bootstrap.md |
165
- | `BOOT_KEY_PREFIX: 'luckystack:boot:'` | Constant — single source of truth so router can't drift. | -> docs/app-bootstrap.md |
166
- | `collectSynchronizedEnvKeys()` / `computeSynchronizedEnvHashes(bootUuid?)` / `hashSynchronizedValue(value, bootUuid?)` | Cross-env drift detection helpers for the router boot handshake. Both hash helpers now honour `http.healthHash` (default `'plain'` = byte-identical to before); the optional `bootUuid?` arg is only needed when `http.healthHash.salt === '@bootUuid'`. Zero-arg callers unchanged. | -> docs/app-bootstrap.md |
167
- | `hashSynchronizedValueWith({ mode, salt }, value)` / `resolveHealthHashConfig(bootUuid?)` | Shared health-hash primitives so the router can hash a local value with the SAME `{mode,salt}` (+ resolved boot UUID) the backend used and the boot-handshake compare still matches. | -> docs/app-bootstrap.md |
168
- | `registerRoomNameFormatter(fn)` / `getRoomNameFormatter()` / `formatRoomName(raw, ctx)` / `defaultRoomNameFormatter` | Room-name formatter registry — route a raw room name through `formatRoomName(raw, { purpose, userId })` (e.g. per-tenant prefixing). Default is identity. Types `RoomNameFormatter` / `RoomNameFormatterContext`. | -> docs/socket-bootstrap.md |
169
- | `applyCookiePrefixConstraints(baseName, prefix, secureOverride?)` (via `cookies` barrel) | Pure `__Host-`/`__Secure-` constraint resolver for the server session-cookie builder (forces `Secure`, forbids `Domain`, pins `Path=/` per the prefix rules). Type `CookiePrefixConstraints`. | -> docs/socket-bootstrap.md |
170
- | `tryCatch<T>(fn): Promise<[Error \| null, T \| null]>` | Tuple-style async error handling used everywhere in the framework. | -> docs/app-bootstrap.md |
171
- | `sleep(ms): Promise<void>` | `setTimeout`-based delay. | -> docs/app-bootstrap.md |
172
- | `getParams(request)` | Parse URL params from a Node request. | -> docs/app-bootstrap.md |
173
- | `serveAvatar(...)` | HTTP handler for `/avatars/:fileId`; rejects path-traversal, applies `AvatarConfig`. | -> docs/app-bootstrap.md |
174
- | `processUpload(input: ProcessUploadInput): Promise<ProcessUploadResult>` | Wraps a project encode/save callback with the `onUploadStart`/`onUploadComplete` hook contract. | -> docs/hooks.md |
175
- | Response normalizers (`normalizeErrorResponse`, `extractLanguageFromHeader`, ...) | Shared error-shape + i18n helpers; dispatches `preErrorNormalize` / `postErrorNormalize` sync hooks. | -> docs/hooks.md |
176
- | `serviceRoute` exports | Helpers to build router-routable URLs across services. | -> docs/socket-bootstrap.md |
177
- | `socketEvents` exports | Wire-protocol constants for socket events. | -> docs/socket-bootstrap.md |
178
- | `paths` exports (`getGeneratedApiDocsPath`, `getApiMethodMapPath`, ...) | Paths resolved through `projectConfig.paths`. | -> docs/config-registry.md |
179
- | `cookies` exports | Cookie parse/serialise helpers used by the HTTP layer. | -> docs/socket-bootstrap.md |
180
- | `httpApiUtils` exports (`inferHttpMethod`, `getEffectiveHttpMethod`, `isMethodAllowed`) | Shared HTTP helpers (method inference + validation). NOTE: `isMethodAllowed` returns false for `OPTIONS` — answer preflights before the route check. | -> docs/socket-bootstrap.md |
181
- | `tryCatchSync<T>(fn): [Error \| null, T \| null]` | Synchronous tuple-style error handling (sync counterpart to `tryCatch`). | -> docs/app-bootstrap.md |
182
- | `deepMerge<T>(base, override)` / `isPlainObject(value)` (`configUtils`) | Shared config deep-merge primitive (every registry routes through it) + plain-object guard. Skips `__proto__`/`constructor`/`prototype` keys. | -> docs/config-registry.md |
183
- | `createRegistry(...)` | Generic DI-slot registry factory backing the config/client/strategy registries. | -> docs/config-registry.md |
184
- | `resolveClientIp({ rawAddress, headers, trustProxy, trustedProxyHopCount })` / `UNKNOWN_CLIENT_IP` | Resolve the real client IP for per-IP rate-limit keying (XFF/x-real-ip only when `trustProxy`). Skips `trustedProxyHopCount` hops from the RIGHT of XFF (default 1 = immediate upstream proxy); never trusts the leftmost client-controlled hop (CORE-O3). | -> docs/app-bootstrap.md |
185
- | `isLoopbackIp(ip: string): boolean` | True for `127.0.0.0/8` / `::1` / `localhost`. Used for `rateLimiting.skipLoopbackInDev` keying (skip the cross-route IP abuse cap for loopback in non-prod). | -> docs/app-bootstrap.md |
186
- | `registerStrayPrefixCommand(...commands)` | Opt a custom single-key Redis command into the `redis` proxy's stray-prefix net. | -> docs/redis-adapter.md |
187
- | `attachSocketRedisAdapter(io, options?)` | Now accepts `{ adapterOptions, pubClient, subClient }` to tune `createAdapter` / supply pre-built clients. | -> docs/redis-adapter.md |
188
-
189
- ### `/client` subpath (browser-safe React + i18n surface)
190
-
191
- Imported from `@luckystack/core/client` (the server barrel intentionally does NOT export these): `apiRequest`, `syncRequest` + sync-callback helpers, the offline-queue API, `registerClientHook` (returns an unsubscribe) + `ClientHookPayloadMap` (`preLogin`/`postLogin`/`postLogout`/`queueItemDropped`), `useTheme`, the `TranslationProvider` + i18n registry, `SessionProvider`/session context, `registerMiddlewareHandler`/`registerPageMiddleware`, and the CSRF-aware `httpFetch`.
192
-
193
- ### `./eslint` subpath
194
-
195
- `@luckystack/core/eslint` exposes the shared ESLint rule set (the CLAUDE.md-invariant rules). Requires the optional `eslint@^9.0.0` peer.
196
-
197
- ## Config keys
198
-
199
- Env vars read directly by core (via `env.ts` and call-time helpers):
200
-
201
- | Env var | Default | Consumer |
202
- | --- | --- | --- |
203
- | `NODE_ENV` | `'development'` | Zod-validated, mirrored to `process.env`. |
204
- | `SERVER_IP` | `'127.0.0.1'` | `getBindAddress()` fallback. |
205
- | `SERVER_PORT` | `'80'` | `getBindAddress()` fallback. |
206
- | `SECURE` | `'false'` | `allowedOrigin` scheme selection. |
207
- | `REDIS_HOST` | `'127.0.0.1'` | `redis.ts` default client + `getRedisConnectionOptions`. |
208
- | `REDIS_PORT` | `'6379'` | Same. |
209
- | `REDIS_USER` | (unset) | Optional ioredis auth. |
210
- | `REDIS_PASSWORD` | (unset) | Optional ioredis auth. |
211
- | `PROJECT_NAME` | `'luckystack'` | Redis-prefix fallback in `getProjectName()`. |
212
- | `LUCKYSTACK_ENV` | (unset) | `resolveEnvKey()` first preference (boot UUID, router handshake). |
213
- | `LUCKYSTACK_ENV_FILES` | `.env,.env.local` | Ambient override for `getEnvFiles()` / `loadEnvFiles()` — comma-separated list of env files to load, "later overrides earlier". |
214
-
215
- `registerProjectConfig` slots (see `ProjectConfig` for full surface): `app.publicUrl`, `logging.*`, `rateLimiting.{enabled, store, redisKeyPrefix, defaultApiLimit, defaultIpLimit, windowMs, cleanupIntervalMs, onStoreError, skipLoopbackInDev, identity, auth}`, `session.{basedToken, expiryDays, perUser, maxConcurrentPerUser, onConflict, notifyOldDeviceOnRevoke, projectName}`, `http.{sessionCookie*, sessionCookieDomain, sessionCookiePrefix, sessionCookieSecure, requestBodyMaxBytes, trustProxy, trustedProxyHopCount, acceptBearerInCookieMode, healthEndpoint, liveEndpoint, readyEndpoint, testResetEndpoint, healthHash, stream, securityHeaders, cors}`, `auth.{credentials, oauthStateTtlSeconds, passwordPolicy, emailMaxLength, nameMaxLength, bcryptRounds, providerAccountStrategy, forgotPassword, passwordResetTtlSeconds, passwordResetBrand, emailChangeTtlSeconds, allowRegistration, passwordResetPath, emailChangeConfirmPath}`, `socket.{maxHttpBufferSize, pingTimeout, pingInterval, activityHeartbeatThrottleMs}`, `api.{requestTimeoutMs}`, `validation.{runtimeMode}`, `sync.{streamThrottle, fanoutYieldEvery, fanoutYieldMs, requestTimeoutMs, allowClientReceiverAll, requireRoomMembership, flushPressure}`, `offlineQueue.{maxSize, maxAgeMs, dropPolicy}`, `dev.{hotReloadDebounceMs, watcherStabilityThresholdMs, watcherPollIntervalMs, warnOnMissingInputType}`, `deploy.routing.{upstreamTimeoutMs, websocketService, routerHealthPath, maxRequestBodyBytes}` (via `registerDeployConfig`), `paths.*`, `defaultLanguage`, `defaultTheme`, `socketActivityBroadcaster`, `socketStatusIndicator`, `locationProviderEnabled`, `loginRedirectUrl`, `oauthCallbackBase`.
216
-
217
- > **New 0.2.0 keys (all additive — a missing key keeps prior behavior EXCEPT `validation.runtimeMode`):** `validation.runtimeMode` (`'enforce'` default — prod input validation now ACTUALLY runs; set `'off'` to restore the old prod no-op). `rateLimiting.skipLoopbackInDev` (default `false`; skip the cross-route IP cap for loopback in dev), `rateLimiting.identity` (callback overriding the per-route bucket basis), `rateLimiting.auth` (`{ enabled false, maxAttempts 5, maxAttemptsPerAccount 50, windowMs 900000 }` — dual lockout counter: per-IP `maxAttempts` + cross-IP `maxAttemptsPerAccount`, ADR 0015`) — per-account login lockout slot). **BREAKING — `sync.allowClientReceiverAll` (default now `false`, was `true`) + `sync.requireRoomMembership` (default now `true`, was `false`): a client can no longer broadcast to `'all'` nor target an unjoined room by default — join the room, approve via `preSyncAuthorize`, or opt back into the permissive values.** `sync.flushPressure` (`{ highWaterMarkChunks 1000, lowWaterMarkChunks 250, maxBufferedBytes 5242880 }`). **BREAKING — `http.healthHash` (`{ mode: 'plain'|'salted'|'hmac', salt: string }`) now DEFAULTS to `{ mode: 'hmac', salt: '@bootUuid' }`: `/_health` no longer exposes a stable, unsalted `sha256(secret)` — the synchronized-env fingerprint is HMAC-keyed on the per-boot UUID (rotates each restart). When no boot UUID is available the `'@bootUuid'` sentinel collapses to `'plain'` so the boot handshake never silently diverges. Set a non-empty `salt` to pin a stable key, or `mode:'plain'` to restore legacy wire output.** `http.sessionCookieDomain`/`sessionCookiePrefix` (`'__Host-'`/`'__Secure-'`)/`sessionCookieSecure`. `http.trustedProxyHopCount` (default `1` — when `trustProxy` is on, the resolved client IP is now the entry that many hops in from the RIGHT of `X-Forwarded-For` (the rightmost hop is the immediate upstream proxy); the leftmost, client-controlled hop is never trusted — CORE-O3 leftmost-spoof fix; clamped to the list length). `http.acceptBearerInCookieMode` (default `false` — in cookie-mode (`session.basedToken:false`) the framework now reads ONLY the session cookie and IGNORES any `Authorization: Bearer` / `handshake.auth.token` fallback, closing the CORE-O10 CSRF-bypass; set `true` to restore the legacy cookie-then-bearer fallback. Token-mode is unaffected). `socket.activityHeartbeatThrottleMs` (default `10000`). `auth.allowRegistration` (default `true`), `auth.passwordResetPath` (default `'/reset-password'`), `auth.emailChangeConfirmPath` (default `'/confirm-email-change'`). `deploy.routing.{upstreamTimeoutMs, websocketService, routerHealthPath, maxRequestBodyBytes}` (all optional — undefined uses the router built-in default).
218
-
219
- Other registries: `registerDeployConfig(DeployConfigShape)`, `registerServicesConfig(ServicesConfigShape)`, `registerAvatarConfig(AvatarConfigInput)`, `registerBindAddress({ ip, port })`.
220
-
221
- ## Peer dependencies
222
-
223
- Required:
224
-
225
- - `@prisma/client@^6.19.0` — DB client proxied through `getPrismaClient()`.
226
- - `ioredis@^5.10.0` — Redis client proxied through `getRedisClient()`; backs the rate limiter, session store, boot-UUID, and offline-queue.
227
- - `socket.io@^4.8.0` — server-side `SocketIOServer` consumed by `setIoInstance` and `attachSocketRedisAdapter`.
228
- - `socket.io-client@^4.8.0` — client-side socket types used by the offline queue.
229
- - `zod@^4.0.0` — `env.ts` schema parsing.
230
-
231
- Optional (mark in `peerDependenciesMeta`):
232
-
233
- - `react@^19.2.0`, `react-dom@^19.2.0`, `react-router-dom@^7.0.0` — only consumed by `@luckystack/core/client` and the `react/*` subpath helpers. Pure server boots can skip these.
234
- - `sonner@^2.0.0` — only needed if you wire the default sonner-backed notifier from the project's React entry; the core notifier slot itself is library-agnostic.
235
- - `eslint@^9.0.0` — only needed to consume the shared rules from the `./eslint` subpath; pure runtime usage doesn't require it.
236
-
237
- Runtime (bundled): `@socket.io/redis-adapter`, `dotenv`.
238
-
239
- ## Related
240
-
241
- - README: `packages/core/README.md`
242
- - Architecture deep-dives: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_PACKAGING.md`, `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_MULTI_INSTANCE.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_SESSION.md`, `docs/ARCHITECTURE_AUTH.md`, `docs/ARCHITECTURE_ROUTING.md`
243
- - Project-side glue: `config.ts`, `server/server.ts`, `server/prod/runtimeMaps.ts`, `src/_sockets/socketInitializer.ts`
1
+ # @luckystack/core
2
+
3
+ > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
+
5
+ ## What this package does
6
+
7
+ Foundation package for LuckyStack. Owns the socket-first transport contracts (`apiMessage`, `syncMessage`, io-instance slot), the registries that every other framework package reads from (project config, deploy/services config, runtime maps, prisma/redis clients, notifier, email senders, error trackers, logger), and the cross-cutting primitives (rate limiter, CORS check, validateRequest, hook bus, response normalizer, offline queue, CSRF, cookies). Every other `@luckystack/*` package depends on this one and never reaches around it.
8
+
9
+ ## When to USE
10
+
11
+ - Reading the active runtime config (`getProjectConfig()`, `getProjectName()`, `getBindAddress()`).
12
+ - Registering DI slots from the project's boot entry (`registerProjectConfig`, `registerPrismaClient`, `registerRedisClient`, `registerLogger`, `registerNotifier`, `registerEmailSender(s)`, `registerErrorTracker(s)`, `registerRuntimeMapsProvider`, `registerApiMethodMap`, `registerAvatarConfig`, `registerBindAddress`, `registerRateLimitStrategy`).
13
+ - Subscribing to lifecycle/transport events via `registerHook(...)` / `registerSyncHook(...)`.
14
+ - Calling the cross-cutting primitives directly when a feature package would be overkill (`tryCatch`, `notify`, `checkRateLimit`, `allowedOrigin`, `validateRequest`, `extractTokenFromSocket`, `extractTokenFromRequest`, the offline queue API).
15
+ - Reading shared transport types (`BaseSessionLayout`, `AuthProps`, `apiMessage`, `syncMessage`, `SOCKETSTATUS`).
16
+
17
+ ## When NOT to suggest yet
18
+
19
+ - Building a feature endpoint: import `@luckystack/api` / `@luckystack/sync` instead — they wrap core's hooks and validation in the file-based routing contract.
20
+ - Sending email: use `@luckystack/email`'s `sendEmail(...)` (it routes through `getEmailSenderByName`).
21
+ - Booting an HTTP server: use `createLuckyStackServer` from `@luckystack/server` — it wires `registerBindAddress`, `setIoInstance`, `attachSocketRedisAdapter`, security headers, etc. for you.
22
+ - Replacing Sentry: register an adapter via `@luckystack/error-tracking` (which calls `registerErrorTracker(s)` here). Do not call `initSharedSentry` in new code.
23
+ - Anything React-coupled: import from `@luckystack/core/client` (browser-safe subpath) or from the consuming feature package's `/client` entry. The server barrel intentionally does NOT export `apiRequest`.
24
+ - Defining project session types: extend `BaseSessionLayout` in your project's own `config.ts` — do not edit the core type.
25
+
26
+ ## Function Index
27
+
28
+ | Function / Export | 1-line | Deep doc |
29
+ | --- | --- | --- |
30
+ | `registerProjectConfig(config: ProjectConfigInput): void` | Deep-merge installer config over `DEFAULT_PROJECT_CONFIG`; call once at boot before any framework code runs. | -> docs/config-registry.md |
31
+ | `getProjectConfig(): ProjectConfig` | Read the active config at call time (never at module load). | -> docs/config-registry.md |
32
+ | `getProjectName(): string` | Resolve the Redis-prefix project name (config -> `PROJECT_NAME` env -> `'luckystack'`). | -> docs/config-registry.md |
33
+ | `isProjectConfigRegistered(): boolean` | Boot-time guard for code paths that must run after registration. | -> docs/config-registry.md |
34
+ | `DEFAULT_PROJECT_CONFIG: ProjectConfig` | Built-in defaults exported so consumers can spread + override structurally. | -> docs/config-registry.md |
35
+ | `registerDeployConfig(config: DeployConfigShape): DeployConfigShape` | Register the project's deploy topology (resources, environments, routing, development). | -> docs/config-registry.md |
36
+ | `getDeployConfig(): DeployConfigShape` | Read deploy topology (defaults to `{ resources: {} }`). | -> docs/config-registry.md |
37
+ | `isDeployConfigRegistered(): boolean` | Guard for router/boot probes that need a registered deploy config. | -> docs/config-registry.md |
38
+ | `registerServicesConfig(config: ServicesConfigShape): ServicesConfigShape` | Register services + presets topology (consumed by router/presetLoader). | -> docs/config-registry.md |
39
+ | `getServicesConfig(): ServicesConfigShape` | Throws when no services config has been registered. | -> docs/config-registry.md |
40
+ | `isServicesConfigRegistered(): boolean` | Non-throwing guard. | -> docs/config-registry.md |
41
+ | `registerAvatarConfig(config: AvatarConfigInput): void` | Override avatar disk format(s) + Cache-Control header. | -> docs/config-registry.md |
42
+ | `getAvatarConfig(): AvatarConfig` | Read active avatar config. | -> docs/config-registry.md |
43
+ | `DEFAULT_AVATAR_CONFIG: AvatarConfig` | Default formats `[{ extension: 'webp', contentType: 'image/webp' }]` + 24h cache. | -> docs/config-registry.md |
44
+ | `registerBindAddress(address: { ip: string; port: number }): void` | `createLuckyStackServer` writes the resolved listen address here at boot. | -> docs/app-bootstrap.md |
45
+ | `getBindAddress(): { ip: string; port: string }` | Resolve at call time (registry -> `SERVER_IP`/`SERVER_PORT` -> `'127.0.0.1'`/`''`). | -> docs/app-bootstrap.md |
46
+ | `registerRuntimeMapsProvider(provider: RuntimeMapsProvider): void` | DI slot for generated api/sync maps; called by project `server/prod/runtimeMaps.ts`. | -> docs/app-bootstrap.md |
47
+ | `getRuntimeApiMaps(): Promise<RuntimeApiMapsResult>` | Async accessor for `{ apisObject, functionsObject }`. | -> docs/app-bootstrap.md |
48
+ | `getRuntimeSyncMaps(): Promise<RuntimeSyncMapsResult>` | Async accessor for `{ syncObject, functionsObject }`. | -> docs/app-bootstrap.md |
49
+ | `isRuntimeMapsProviderRegistered(): boolean` | Boot-fail-mode detector for `verifyBootstrap`. | -> docs/app-bootstrap.md |
50
+ | `registerApiMethodMap(map: ApiMethodMap): void` | Register generated `apiMethodMap` so `apiRequest` can look up real HTTP methods. | -> docs/app-bootstrap.md |
51
+ | `getRegisteredApiMethod(pagePath: string, apiName: string, version: string): HttpMethodLiteral \| undefined` | Lookup helper used by `isGetMethod`. | -> docs/app-bootstrap.md |
52
+ | `isApiMethodMapRegistered(): boolean` | Detect whether the prefix-heuristic fallback is active. | -> docs/app-bootstrap.md |
53
+ | `registerPrismaClient(client: PrismaClient, key?: string): PrismaClient` | Register a Prisma client into a slot (default `'default'`). Pass a `key` for graded credentials (e.g. `'ro'`/`'rw'`) or per-tenant clients. | -> docs/app-bootstrap.md |
54
+ | `registerRedisClient(client: RedisClient, key?: string): RedisClient` | Register an ioredis client into a slot (default `'default'`). | -> docs/app-bootstrap.md |
55
+ | `getPrismaClient(): PrismaClient` | Read the `'default'` slot (registered client or lazy default resolver). | -> docs/app-bootstrap.md |
56
+ | `getRedisClient(): RedisClient` | Read the `'default'` slot (registered client or lazy default resolver). | -> docs/app-bootstrap.md |
57
+ | `getPrismaClientFor(key?: string): PrismaClient` | Read a specific slot. `'default'` falls back to the resolver; any other unregistered slot throws (never silently returns the privileged default). | -> docs/app-bootstrap.md |
58
+ | `getRedisClientFor(key?: string): RedisClient` | Read a specific Redis slot (same semantics as `getPrismaClientFor`). | -> docs/app-bootstrap.md |
59
+ | `getPrismaClientKeys(): string[]` / `getRedisClientKeys(): string[]` | Diagnostic — which slots have an explicitly-registered client. | -> docs/app-bootstrap.md |
60
+ | `DEFAULT_CLIENT_KEY: 'default'` | The reserved slot backing every framework internal + the `prisma`/`redis` proxies. | -> docs/app-bootstrap.md |
61
+ | `isPrismaClientRegistered(): boolean` | Boot guard — true when the `'default'` slot is registered. | -> docs/app-bootstrap.md |
62
+ | `isRedisClientRegistered(): boolean` | Boot guard — true when the `'default'` slot is registered. | -> docs/app-bootstrap.md |
63
+ | `resetClientsForTests(): void` | Test-only — drop every registered slot (default resolvers stay set). | -> docs/app-bootstrap.md |
64
+ | `isPrismaClientResolvable(): boolean` | Is a generated `@prisma/client` present in this install? (ADR 0020 — `@prisma/client` is lazily require()d, never statically imported: with `orm: 'none'` it may legitimately be absent; a DB access without a registered client then throws an actionable error pointing at `luckystack/core/clients.ts`.) | -> docs/app-bootstrap.md |
65
+ | `registerDbHealthCheck(check)` / `getDbHealthCheck()` / `isDbHealthCheckRegistered()` / `resetDbHealthCheckForTests()` | Pluggable `/readyz` database probe (ADR 0020): a registered check wins; without one the server falls back to the built-in Prisma ping when Prisma is present, else reports `'skipped'` so a DB-less project can go ready. Types `DbHealthCheck` / `DbHealthResult` (`boolean \| 'skipped'`). | -> docs/app-bootstrap.md |
66
+ | `redis` | Proxy that forwards every call to the currently registered ioredis client. Applies `applyStrayKeyPrefix` to single-key commands as a best-effort namespace net (keys containing `:` pass through untouched). | -> docs/redis-adapter.md |
67
+ | `getRedisConnectionOptions(): RedisConnectionOptions` | Single source of truth for `{ host, port, username?, password? }` (router reuses for cross-env probes). | -> docs/redis-adapter.md |
68
+ | `formatKey(namespace: string, suffix?: string): string` | Build a namespaced Redis key through the active formatter. The single authority every framework key-site routes through (`-session`, `-activeUsers`, `-pwreset`, `-email-change`, `-oauth-state`, `:rate-limit`). Default reproduces historical key bytes (zero migration). | -> docs/redis-adapter.md |
69
+ | `registerRedisKeyFormatter(fn: RedisKeyFormatter): void` | Override how every framework Redis key is namespaced (multi-tenant per-tenant prefixing). Must preserve the `<namespace-root>:<suffix>` join so `SCAN` enumeration still works. | -> docs/redis-adapter.md |
70
+ | `getRedisKeyFormatter(): RedisKeyFormatter` / `defaultRedisKeyFormatter` / `resetRedisKeyFormatterForTests()` | Read the active formatter / the built-in default / test reset. | -> docs/redis-adapter.md |
71
+ | `applyStrayKeyPrefix(key: string): string` | Project-prefix an un-namespaced (colon-free) key; pass-through for already-namespaced keys. Used by the `redis` proxy net. | -> docs/redis-adapter.md |
72
+ | `acquireLease(name, ttlMs): Promise<string \| null>` | Acquire an exclusive Redis lease (`SET NX PX`). Returns an owner token, or null if held. Single-Redis best-effort (not Redlock) — the lease is a primitive; the renew loop is app code. | -> docs/redis-adapter.md |
73
+ | `renewLease(name, token, ttlMs): Promise<boolean>` | Owner-checked compare-and-pexpire (Lua); extends only if `token` still holds the lease. | -> docs/redis-adapter.md |
74
+ | `releaseLease(name, token): Promise<boolean>` | Owner-checked compare-and-delete (Lua); releasing another owner's lease is a no-op. | -> docs/redis-adapter.md |
75
+ | `attachSocketRedisAdapter(io: SocketIOServer): void` | Wire `@socket.io/redis-adapter` with duplicated pub/sub clients so room broadcasts span instances. | -> docs/redis-adapter.md |
76
+ | `setIoInstance(io: SocketIOServer \| null): void` | Module-level slot for the running Socket.io server. | -> docs/socket-bootstrap.md |
77
+ | `getIoInstance(): SocketIOServer \| null` | Read the slot from framework code that needs to broadcast. | -> docs/socket-bootstrap.md |
78
+ | `socket`, `setSocket`, `incrementResponseIndex`, `waitForSocket` | Client-side socket singleton + response-index counter. | -> docs/socket-bootstrap.md |
79
+ | `extractTokenFromSocket(socket): string \| null` | Read session token from Socket.io handshake. Token-mode reads `handshake.auth.token` then cookie; cookie-mode reads ONLY the cookie unless `http.acceptBearerInCookieMode` is true (CORE-O10). | -> docs/socket-bootstrap.md |
80
+ | `extractTokenFromRequest(request): string \| null` | Read session token from a Node IncomingMessage. Token-mode reads `Authorization: Bearer` then cookie; cookie-mode reads ONLY the cookie unless `http.acceptBearerInCookieMode` is true (CORE-O10). | -> docs/socket-bootstrap.md |
81
+ | `allowedOrigin(origin: string): boolean` | Same-origin + project-configured allow-list CORS check; dispatches `corsRejected` hook on miss. | -> docs/socket-bootstrap.md |
82
+ | `validateRequest({ auth, user }): ValidationResult` | Evaluates the `auth.additional[]` predicates against the session; returns success immediately when `additional` is absent. Does NOT check `auth.login` — login is enforced by the surrounding API/sync handler, not by this function. | -> docs/session-types.md |
83
+ | `isFalsy(value): boolean` | Helper used inside `validateRequest`. | -> docs/session-types.md |
84
+ | `validateInputByType(value, type)` | Runtime input validation; lazy-loads `@luckystack/devkit` in dev. | -> docs/session-types.md |
85
+ | `registerNotifier(notifier: Notifier): void` | DI for client-side toast notifier (success/error/info/warning). | -> docs/app-bootstrap.md |
86
+ | `getNotifier(): Notifier` | Read active notifier (no-op default). | -> docs/app-bootstrap.md |
87
+ | `notify: Notifier` | Delegating wrapper used by framework hot paths. | -> docs/app-bootstrap.md |
88
+ | `registerEmailSender(sender: EmailSender): void` | Legacy single-sender registration; mirrors into `default` slot. | -> docs/app-bootstrap.md |
89
+ | `registerEmailSenders(senders: EmailSenderRegistry): void` | Multi-adapter registration (`'default'`, `'transactional'`, `'marketing'`, custom). | -> docs/app-bootstrap.md |
90
+ | `getEmailSender(): EmailSender \| null` | Read legacy/`default` sender. | -> docs/app-bootstrap.md |
91
+ | `getEmailSenderByName(name: string): EmailSender \| null` | Read a specific slot (falls back to legacy sender for `'default'`). | -> docs/app-bootstrap.md |
92
+ | `listEmailSenderNames(): string[]` | Diagnostic helper. | -> docs/app-bootstrap.md |
93
+ | `isEmailSenderRegistered(): boolean` | Boot guard. | -> docs/app-bootstrap.md |
94
+ | `registerLogger(logger: Logger): void` | Plug in Pino/Winston/Datadog (or `createDevLogger()` for colored dev output). | -> docs/app-bootstrap.md |
95
+ | `getLogger(): Logger` | Read at call time (default = console.{debug,info,warn,error}). | -> docs/app-bootstrap.md |
96
+ | `isLoggerRegistered(): boolean` | Boot guard. | -> docs/app-bootstrap.md |
97
+ | `createDevLogger(): Logger` | Factory for the dev-mode colored logger (depends on `initConsolelog()`). | -> docs/app-bootstrap.md |
98
+ | `resetLoggerForTests(): void` | Test-only — restore default logger. | -> docs/app-bootstrap.md |
99
+ | `registerRedactedLogKeys(keys: Iterable<string>): void` | Add keys that the framework will redact from log payloads. | -> docs/app-bootstrap.md |
100
+ | `getRedactedLogKeys(): Set<string>` | Read current redacted-keys set. | -> docs/app-bootstrap.md |
101
+ | `isRedactedLogKey(key: string): boolean` | Hot-path lookup used inside the framework. Matches the exact registered set AND any key whose lowercased form ENDS WITH a sensitive suffix (`token`/`secret`/`apikey`/`password`) — so `targetToken` / `clientSecret` / `stripeApiKey` redact without being registered, while `tokenCount` / `secretSanta` stay untouched. | -> docs/app-bootstrap.md |
102
+ | `resetRedactedLogKeysForTests(): void` | Test-only reset. | -> docs/app-bootstrap.md |
103
+ | `sanitizeForLog(value): unknown` | Recursive redaction pass — deep-clones `value`, replacing any redacted-key field with `REDACTED_PLACEHOLDER`. Applied on the `captureException`/`captureMessage` fan-out so a raw token nested in context never reaches an adapter (SYNC-17). | -> docs/app-bootstrap.md |
104
+ | `DEFAULT_REDACTED_LOG_KEYS: readonly string[]` | The built-in masked-key set (token, password, authorization, cookie, csrfToken, apiKey, secret, …) seeded into the redacted-keys registry. Widened in 0.2.0 (added `csrftoken`/`apikey`/`secret`) plus suffix matching in `isRedactedLogKey`. | -> docs/app-bootstrap.md |
105
+ | `REDACTED_PLACEHOLDER: string` | The constant `sanitizeForLog` substitutes for a redacted value. | -> docs/app-bootstrap.md |
106
+ | `initConsolelog(): void` | Monkey-patch `console.*` to render trailing color string (dev only). | -> docs/app-bootstrap.md |
107
+ | `registerLocaleReloader(reloader: LocaleReloader): void` | DI for the dev-only i18n hot-reload trigger. | -> docs/app-bootstrap.md |
108
+ | `getLocaleReloader(): LocaleReloader \| null` | Read active reloader (returns null when no project supplied one). | -> docs/app-bootstrap.md |
109
+ | `registerErrorTracker(tracker: ErrorTracker): void` | Single-tracker registration (replaces previous). | -> docs/error-tracker-registry.md |
110
+ | `registerErrorTrackers(trackers: ErrorTracker[]): void` | Multi-tracker registration (replaces the list). | -> docs/error-tracker-registry.md |
111
+ | `appendErrorTracker(tracker: ErrorTracker): void` | Accumulate-not-replace registration — appends a tracker, deduping by `ErrorTracker.name`. Use for async auto-registration (e.g. PostHog) so it can't clobber a consumer overlay. | -> docs/error-tracker-registry.md |
112
+ | `runWithErrorTrackerIdentity(user, fn): T` / `getCurrentErrorTrackerIdentity(): ErrorTrackerUser \| null` | AsyncLocalStorage per-event identity — wrap request handling in `runWithErrorTrackerIdentity(user, fn)` and capture sites read the current identity instead of a mutable global (no cross-request bleed). | -> docs/error-tracker-registry.md |
113
+ | `registerPreCaptureFilter(filter: PreCaptureFilter): void` | Register a filter run before every capture fan-out (drop/transform events centrally). | -> docs/error-tracker-registry.md |
114
+ | `startSpanHandle(name, op): SpanHandle` | Handle-style span — returns a `{ finish() }` handle (vs the callback-style `startSpanAcrossTrackers`). | -> docs/error-tracker-registry.md |
115
+ | `flushErrorTrackers(): Promise<void>` | Flush lifecycle — calls every adapter's optional `flush?()`; call on shutdown so buffered events aren't lost. | -> docs/error-tracker-registry.md |
116
+ | `getActiveErrorTrackers(): ErrorTracker[]` | Read active trackers. | -> docs/error-tracker-registry.md |
117
+ | `captureExceptionAcrossTrackers(error, context?): void` | Fan-out exception capture; per-tracker errors are swallowed. | -> docs/error-tracker-registry.md |
118
+ | `captureMessageAcrossTrackers(message, level, context?): void` | Fan-out message capture. | -> docs/error-tracker-registry.md |
119
+ | `setErrorTrackerUser(user: ErrorTrackerUser \| null): void` | Fan-out user identification. | -> docs/error-tracker-registry.md |
120
+ | `recordMetricAcrossTrackers(name, value, tags?): void` | Optional metric fan-out (skips trackers without `recordMetric`). | -> docs/error-tracker-registry.md |
121
+ | `startSpanAcrossTrackers<T>(name, op, fn): T` | First tracker that supports spans wins; falls back to direct `fn()`. | -> docs/error-tracker-registry.md |
122
+ | `initSharedSentry(instance)` | Legacy Sentry slot (kept for backwards compatibility). | -> docs/error-tracker-registry.md |
123
+ | `captureException(error, context?): void` | Legacy fan-out: Sentry slot + adapter list. | -> docs/error-tracker-registry.md |
124
+ | `captureMessage(message, level?, context?): void` | Legacy fan-out: Sentry slot + adapter list. | -> docs/error-tracker-registry.md |
125
+ | `setSentryUser(user): void` | Legacy fan-out: Sentry slot + adapter list. | -> docs/error-tracker-registry.md |
126
+ | `startSpan(name, op): unknown` | Legacy span helper. | -> docs/error-tracker-registry.md |
127
+ | `checkRateLimit(params: CheckRateLimitParams): Promise<RateLimitResult>` | Delegates to the active strategy's `check()`. | -> docs/rate-limit-strategy.md |
128
+ | `getRateLimitStatus(key, limit): Promise<RateLimitResult>` | Read-only status (does not increment). | -> docs/rate-limit-strategy.md |
129
+ | `clearRateLimit(key): Promise<void>` | Reset a single key. | -> docs/rate-limit-strategy.md |
130
+ | `clearAllRateLimits(): Promise<void>` | Reset every key under the active strategy's namespace. | -> docs/rate-limit-strategy.md |
131
+ | `registerRateLimitStrategy(strategy: RateLimitStrategy): void` | Swap the backend (token-bucket, edge-KV, per-tier, no-op). | -> docs/rate-limit-strategy.md |
132
+ | `getRateLimitStrategy(): RateLimitStrategy` | Read the currently active strategy. | -> docs/rate-limit-strategy.md |
133
+ | `defaultRateLimitStrategy: RateLimitStrategy` | Built-in memory-or-redis backend. | -> docs/rate-limit-strategy.md |
134
+ | `registerHook<TName>(name, handler): void` | Subscribe an async handler to a lifecycle event. | -> docs/hooks.md |
135
+ | `registerMiddlewareHandler(handler: MiddlewareHandler): void` | Register the GLOBAL route-guard fallback. Called by `<Middleware>` / `useRouter` only when no per-page middleware is registered for the visited route. Optional — framework default allows by-default. | -> docs/hooks.md |
136
+ | `getMiddlewareHandler(): MiddlewareHandler` | Read the active global handler. | -> docs/hooks.md |
137
+ | `registerPageMiddleware(path: string, fn: PageMiddleware): void` | Register a per-page route guard. Auto-called by `src/main.tsx`'s `getRoutes()` for every page that exports `middleware`. Per-page takes precedence over the global handler. | -> docs/hooks.md |
138
+ | `getPageMiddleware(path: string): PageMiddleware \| undefined` | Lookup the per-page guard for a given route. Framework-internal. | -> docs/hooks.md |
139
+ | `hasPageMiddleware(path: string): boolean` | Existence check. | -> docs/hooks.md |
140
+ | `MiddlewareInput`, `MiddlewareResult`, `MiddlewareHandler`, `PageMiddleware` (types) | Route-guard contract types. Both handler shapes share the same signature. | -> docs/hooks.md |
141
+ | `validatePagePath(srcRelativePath, rules?): PagePathValidationResult` | Pure validator for the invisible-parent folder convention used by `src/main.tsx`'s page auto-discovery AND the `scaffold:page` CLI. Returns `{ valid, route?, reason? }`. | -> docs/hooks.md |
142
+ | `DEFAULT_PAGE_ROUTE_RULES: PageRouteRules` | Default config (private-folder prefix `'_'` + reserved framework folders). | -> docs/hooks.md |
143
+ | `dispatchHook<TName>(name, payload): Promise<DispatchResult>` | Internal: framework code invokes registered handlers in order; first stop signal aborts. | -> docs/hooks.md |
144
+ | `clearAllHooks(): void` | Test-only — drop every registered handler (sync + async). | -> docs/hooks.md |
145
+ | `registerSyncHook<TName>(name, handler): void` | Subscribe a synchronous mutator to a hot-path hook (e.g. error normalization). | -> docs/hooks.md |
146
+ | `dispatchSyncHook<TName>(name, payload): void` | Internal: framework code invokes sync handlers; payload is mutated in place. | -> docs/hooks.md |
147
+ | `BaseSessionLayout`, `SessionLocation`, `AuthProps` (types) | Foundational session-shape types; project `SessionLayout` extends `BaseSessionLayout`. | -> docs/session-types.md |
148
+ | `HookSessionShape`, `HookName`, `HookHandler`, `HookResult`, `HookStopSignal`, `HookPayloads` (types) | Hook contract types (augmentable via TS module augmentation). | -> docs/hooks.md |
149
+ | `preServerStop` hook + `PreServerStopPayload` (type) | Graceful-shutdown lifecycle hook — `@luckystack/server` dispatches it once on SIGTERM/SIGINT before the server stops accepting connections. Payload `{ reason, timeoutMs? }`. Best-effort (a stop signal does NOT abort shutdown); use to flush trackers / drain queues / close pools. | -> docs/hooks.md |
150
+ | `isOnline()` / `enqueueApiRequest` / `enqueueSyncRequest` / `removeApiQueueItem` / `removeSyncQueueItem` / `removeApiQueueItemsByKey` / `flushApiQueue` / `flushSyncQueue` / `getApiQueueSize` / `getSyncQueueSize` | Client-side offline queue with per-item `dropPolicy` and global max-size/max-age caps. | -> docs/socket-bootstrap.md |
151
+ | `getCsrfToken()`, `clearCsrfToken()`, `httpFetch(...)` | CSRF-aware fetch wrapper used by the client transport. | -> docs/socket-bootstrap.md |
152
+ | `issueOneTimeToken(namespace, ttlSeconds, payload): OneTimeTokenHandle` | Mint a single-use Redis-backed token (returns `{ token, store() }`). HASHED AT REST — only `sha256(token)` is stored as the key, never the raw token. Used by `@luckystack/login` for password-reset + email-change links. | -> docs/app-bootstrap.md |
153
+ | `consumeOneTimeToken(namespace, token): Promise<string \| null>` | Atomically validate + consume a one-time token (single `MULTI` GET+DEL → at-most-once). Returns the stored payload string, or null on miss/expired/reused. | -> docs/app-bootstrap.md |
154
+ | `consumeOneTimeTokenJson<T>(namespace, token): Promise<T \| null>` | `consumeOneTimeToken` + JSON-parse; null on miss OR malformed payload. | -> docs/app-bootstrap.md |
155
+ | `OneTimeTokenHandle` (type) | `{ token: string; store(): Promise<void> }` returned by `issueOneTimeToken`. | -> docs/app-bootstrap.md |
156
+ | `registerCsrfConfig(input: Partial<CsrfConfig>): void` | Override the CSRF cookie name, header name, token length, or cookie options. | -> docs/csrf-config.md |
157
+ | `getCsrfConfig(): CsrfConfig` | Read the active CSRF config at call time (defaults to `DEFAULT_CSRF_CONFIG`). | -> docs/csrf-config.md |
158
+ | `DEFAULT_CSRF_CONFIG: CsrfConfig` | Built-in defaults (`csrf-token` cookie, `x-csrf-token` header, 32-byte token). | -> docs/csrf-config.md |
159
+ | `resetCsrfConfigForTests(): void` | Test-only — restore CSRF defaults between scenarios. | -> docs/csrf-config.md |
160
+ | `registerSocketMiddleware(mw: SocketMiddleware): void` | Wedge an `io.use(...)` middleware into the framework's socket bootstrap; runs before any `connect` handler. | -> docs/socket-bootstrap.md |
161
+ | `getSocketMiddlewares(): readonly SocketMiddleware[]` | Read the registered middleware list. | -> docs/socket-bootstrap.md |
162
+ | `clearSocketMiddlewares(): void` | Drop every registered middleware (test/hot-reload helper). | -> docs/socket-bootstrap.md |
163
+ | `applySocketMiddlewares(io: SocketIOServer): void` | Wire every registered middleware into the running Socket.io server (called from `@luckystack/server`'s `loadSocket`). | -> docs/socket-bootstrap.md |
164
+ | `writeBootUuid(envKey?): Promise<string>` | Write a fresh UUID to `luckystack:boot:<envKey>` with the configured TTL. | -> docs/app-bootstrap.md |
165
+ | `readBootUuid(envKey?): Promise<string \| null>` | Read the boot UUID (router cross-checks against `/_health`). | -> docs/app-bootstrap.md |
166
+ | `resolveEnvKey(): string` | `LUCKYSTACK_ENV` -> `NODE_ENV` -> `'development'`. | -> docs/app-bootstrap.md |
167
+ | `BOOT_KEY_PREFIX: 'luckystack:boot:'` | Constant — single source of truth so router can't drift. | -> docs/app-bootstrap.md |
168
+ | `collectSynchronizedEnvKeys()` / `computeSynchronizedEnvHashes(bootUuid?)` / `hashSynchronizedValue(value, bootUuid?)` | Cross-env drift detection helpers for the router boot handshake. Both hash helpers now honour `http.healthHash` (default `'plain'` = byte-identical to before); the optional `bootUuid?` arg is only needed when `http.healthHash.salt === '@bootUuid'`. Zero-arg callers unchanged. | -> docs/app-bootstrap.md |
169
+ | `hashSynchronizedValueWith({ mode, salt }, value)` / `resolveHealthHashConfig(bootUuid?)` | Shared health-hash primitives so the router can hash a local value with the SAME `{mode,salt}` (+ resolved boot UUID) the backend used and the boot-handshake compare still matches. | -> docs/app-bootstrap.md |
170
+ | `registerRoomNameFormatter(fn)` / `getRoomNameFormatter()` / `formatRoomName(raw, ctx)` / `defaultRoomNameFormatter` | Room-name formatter registry — route a raw room name through `formatRoomName(raw, { purpose, userId })` (e.g. per-tenant prefixing). Default is identity. Types `RoomNameFormatter` / `RoomNameFormatterContext`. | -> docs/socket-bootstrap.md |
171
+ | `applyCookiePrefixConstraints(baseName, prefix, secureOverride?)` (via `cookies` barrel) | Pure `__Host-`/`__Secure-` constraint resolver for the server session-cookie builder (forces `Secure`, forbids `Domain`, pins `Path=/` per the prefix rules). Type `CookiePrefixConstraints`. | -> docs/socket-bootstrap.md |
172
+ | `tryCatch<T>(fn): Promise<[Error \| null, T \| null]>` | Tuple-style async error handling used everywhere in the framework. | -> docs/app-bootstrap.md |
173
+ | `sleep(ms): Promise<void>` | `setTimeout`-based delay. | -> docs/app-bootstrap.md |
174
+ | `getParams(request)` | Parse URL params from a Node request. | -> docs/app-bootstrap.md |
175
+ | `serveAvatar(...)` | HTTP handler for `/avatars/:fileId`; rejects path-traversal, applies `AvatarConfig`. | -> docs/app-bootstrap.md |
176
+ | `processUpload(input: ProcessUploadInput): Promise<ProcessUploadResult>` | Wraps a project encode/save callback with the `onUploadStart`/`onUploadComplete` hook contract. | -> docs/hooks.md |
177
+ | Response normalizers (`normalizeErrorResponse`, `extractLanguageFromHeader`, ...) | Shared error-shape + i18n helpers; dispatches `preErrorNormalize` / `postErrorNormalize` sync hooks. | -> docs/hooks.md |
178
+ | `serviceRoute` exports | Helpers to build router-routable URLs across services. | -> docs/socket-bootstrap.md |
179
+ | `socketEvents` exports | Wire-protocol constants for socket events. | -> docs/socket-bootstrap.md |
180
+ | `paths` exports (`getGeneratedApiDocsPath`, `getApiMethodMapPath`, ...) | Paths resolved through `projectConfig.paths`. | -> docs/config-registry.md |
181
+ | `cookies` exports | Cookie parse/serialise helpers used by the HTTP layer. | -> docs/socket-bootstrap.md |
182
+ | `httpApiUtils` exports (`inferHttpMethod`, `getEffectiveHttpMethod`, `isMethodAllowed`) | Shared HTTP helpers (method inference + validation). NOTE: `isMethodAllowed` returns false for `OPTIONS` — answer preflights before the route check. | -> docs/socket-bootstrap.md |
183
+ | `tryCatchSync<T>(fn): [Error \| null, T \| null]` | Synchronous tuple-style error handling (sync counterpart to `tryCatch`). | -> docs/app-bootstrap.md |
184
+ | `deepMerge<T>(base, override)` / `isPlainObject(value)` (`configUtils`) | Shared config deep-merge primitive (every registry routes through it) + plain-object guard. Skips `__proto__`/`constructor`/`prototype` keys. | -> docs/config-registry.md |
185
+ | `createRegistry(...)` | Generic DI-slot registry factory backing the config/client/strategy registries. | -> docs/config-registry.md |
186
+ | `resolveClientIp({ rawAddress, headers, trustProxy, trustedProxyHopCount })` / `UNKNOWN_CLIENT_IP` | Resolve the real client IP for per-IP rate-limit keying (XFF/x-real-ip only when `trustProxy`). Skips `trustedProxyHopCount` hops from the RIGHT of XFF (default 1 = immediate upstream proxy); never trusts the leftmost client-controlled hop (CORE-O3). | -> docs/app-bootstrap.md |
187
+ | `isLoopbackIp(ip: string): boolean` | True for `127.0.0.0/8` / `::1` / `localhost`. Used for `rateLimiting.skipLoopbackInDev` keying (skip the cross-route IP abuse cap for loopback in non-prod). | -> docs/app-bootstrap.md |
188
+ | `registerStrayPrefixCommand(...commands)` | Opt a custom single-key Redis command into the `redis` proxy's stray-prefix net. | -> docs/redis-adapter.md |
189
+ | `attachSocketRedisAdapter(io, options?)` | Now accepts `{ adapterOptions, pubClient, subClient }` to tune `createAdapter` / supply pre-built clients. | -> docs/redis-adapter.md |
190
+
191
+ ### `/client` subpath (browser-safe React + i18n surface)
192
+
193
+ Imported from `@luckystack/core/client` (the server barrel intentionally does NOT export these): `apiRequest`, `syncRequest` + sync-callback helpers, the offline-queue API, `registerClientHook` (returns an unsubscribe) + `ClientHookPayloadMap` (`preLogin`/`postLogin`/`postLogout`/`queueItemDropped`), `useTheme`, the `TranslationProvider` + i18n registry, `SessionProvider`/session context, `registerMiddlewareHandler`/`registerPageMiddleware`, and the CSRF-aware `httpFetch`.
194
+
195
+ ### `./eslint` subpath
196
+
197
+ `@luckystack/core/eslint` exposes the shared ESLint rule set (the CLAUDE.md-invariant rules). Requires the optional `eslint@^9.0.0` peer.
198
+
199
+ ## Config keys
200
+
201
+ Env vars read directly by core (via `env.ts` and call-time helpers):
202
+
203
+ | Env var | Default | Consumer |
204
+ | --- | --- | --- |
205
+ | `NODE_ENV` | `'development'` | Zod-validated, mirrored to `process.env`. |
206
+ | `SERVER_IP` | `'127.0.0.1'` | `getBindAddress()` fallback. |
207
+ | `SERVER_PORT` | `'80'` | `getBindAddress()` fallback. |
208
+ | `SECURE` | `'false'` | `allowedOrigin` scheme selection. |
209
+ | `REDIS_HOST` | `'127.0.0.1'` | `redis.ts` default client + `getRedisConnectionOptions`. |
210
+ | `REDIS_PORT` | `'6379'` | Same. |
211
+ | `REDIS_USER` | (unset) | Optional ioredis auth. |
212
+ | `REDIS_PASSWORD` | (unset) | Optional ioredis auth. |
213
+ | `PROJECT_NAME` | `'luckystack'` | Redis-prefix fallback in `getProjectName()`. |
214
+ | `LUCKYSTACK_ENV` | (unset) | `resolveEnvKey()` first preference (boot UUID, router handshake). |
215
+ | `LUCKYSTACK_ENV_FILES` | `.env,.env.local` | Ambient override for `getEnvFiles()` / `loadEnvFiles()` — comma-separated list of env files to load, "later overrides earlier". |
216
+
217
+ `registerProjectConfig` slots (see `ProjectConfig` for full surface): `app.publicUrl`, `logging.*`, `rateLimiting.{enabled, store, redisKeyPrefix, defaultApiLimit, defaultIpLimit, windowMs, cleanupIntervalMs, onStoreError, skipLoopbackInDev, identity, auth}`, `session.{basedToken, expiryDays, perUser, maxConcurrentPerUser, onConflict, notifyOldDeviceOnRevoke, projectName}`, `http.{sessionCookie*, sessionCookieDomain, sessionCookiePrefix, sessionCookieSecure, requestBodyMaxBytes, trustProxy, trustedProxyHopCount, acceptBearerInCookieMode, healthEndpoint, liveEndpoint, readyEndpoint, testResetEndpoint, healthHash, stream, securityHeaders, cors}`, `auth.{credentials, oauthStateTtlSeconds, passwordPolicy, emailMaxLength, nameMaxLength, bcryptRounds, providerAccountStrategy, forgotPassword, passwordResetTtlSeconds, passwordResetBrand, emailChangeTtlSeconds, allowRegistration, passwordResetPath, emailChangeConfirmPath}`, `socket.{maxHttpBufferSize, pingTimeout, pingInterval, activityHeartbeatThrottleMs}`, `api.{requestTimeoutMs}`, `validation.{runtimeMode}`, `sync.{streamThrottle, fanoutYieldEvery, fanoutYieldMs, requestTimeoutMs, allowClientReceiverAll, requireRoomMembership, flushPressure}`, `offlineQueue.{maxSize, maxAgeMs, dropPolicy}`, `dev.{hotReloadDebounceMs, watcherStabilityThresholdMs, watcherPollIntervalMs, warnOnMissingInputType}`, `deploy.routing.{upstreamTimeoutMs, websocketService, routerHealthPath, maxRequestBodyBytes}` (via `registerDeployConfig`), `paths.*`, `defaultLanguage`, `defaultTheme`, `socketActivityBroadcaster`, `socketStatusIndicator`, `locationProviderEnabled`, `loginRedirectUrl`, `oauthCallbackBase`.
218
+
219
+ > **New 0.2.0 keys (all additive — a missing key keeps prior behavior EXCEPT `validation.runtimeMode`):** `validation.runtimeMode` (`'enforce'` default — prod input validation now ACTUALLY runs; set `'off'` to restore the old prod no-op). `rateLimiting.skipLoopbackInDev` (default `false`; skip the cross-route IP cap for loopback in dev), `rateLimiting.identity` (callback overriding the per-route bucket basis), `rateLimiting.auth` (`{ enabled false, maxAttempts 5, maxAttemptsPerAccount 50, windowMs 900000 }` — dual lockout counter: per-IP `maxAttempts` + cross-IP `maxAttemptsPerAccount`, ADR 0015`) — per-account login lockout slot). **BREAKING — `sync.allowClientReceiverAll` (default now `false`, was `true`) + `sync.requireRoomMembership` (default now `true`, was `false`): a client can no longer broadcast to `'all'` nor target an unjoined room by default — join the room, approve via `preSyncAuthorize`, or opt back into the permissive values.** `sync.flushPressure` (`{ highWaterMarkChunks 1000, lowWaterMarkChunks 250, maxBufferedBytes 5242880 }`). **BREAKING — `http.healthHash` (`{ mode: 'plain'|'salted'|'hmac', salt: string }`) now DEFAULTS to `{ mode: 'hmac', salt: '@bootUuid' }`: `/_health` no longer exposes a stable, unsalted `sha256(secret)` — the synchronized-env fingerprint is HMAC-keyed on the per-boot UUID (rotates each restart). When no boot UUID is available the `'@bootUuid'` sentinel collapses to `'plain'` so the boot handshake never silently diverges. Set a non-empty `salt` to pin a stable key, or `mode:'plain'` to restore legacy wire output.** `http.sessionCookieDomain`/`sessionCookiePrefix` (`'__Host-'`/`'__Secure-'`)/`sessionCookieSecure`. `http.trustedProxyHopCount` (default `1` — when `trustProxy` is on, the resolved client IP is now the entry that many hops in from the RIGHT of `X-Forwarded-For` (the rightmost hop is the immediate upstream proxy); the leftmost, client-controlled hop is never trusted — CORE-O3 leftmost-spoof fix; clamped to the list length). `http.acceptBearerInCookieMode` (default `false` — in cookie-mode (`session.basedToken:false`) the framework now reads ONLY the session cookie and IGNORES any `Authorization: Bearer` / `handshake.auth.token` fallback, closing the CORE-O10 CSRF-bypass; set `true` to restore the legacy cookie-then-bearer fallback. Token-mode is unaffected). `socket.activityHeartbeatThrottleMs` (default `10000`). `auth.allowRegistration` (default `true`), `auth.passwordResetPath` (default `'/reset-password'`), `auth.emailChangeConfirmPath` (default `'/confirm-email-change'`). `deploy.routing.{upstreamTimeoutMs, websocketService, routerHealthPath, maxRequestBodyBytes}` (all optional — undefined uses the router built-in default).
220
+
221
+ Other registries: `registerDeployConfig(DeployConfigShape)`, `registerServicesConfig(ServicesConfigShape)`, `registerAvatarConfig(AvatarConfigInput)`, `registerBindAddress({ ip, port })`.
222
+
223
+ ## Peer dependencies
224
+
225
+ Required:
226
+
227
+ - `@prisma/client@^6.19.0` — DB client proxied through `getPrismaClient()`.
228
+ - `ioredis@^5.10.0` — Redis client proxied through `getRedisClient()`; backs the rate limiter, session store, boot-UUID, and offline-queue.
229
+ - `socket.io@^4.8.0` — server-side `SocketIOServer` consumed by `setIoInstance` and `attachSocketRedisAdapter`.
230
+ - `socket.io-client@^4.8.0` — client-side socket types used by the offline queue.
231
+ - `zod@^4.0.0` — `env.ts` schema parsing.
232
+
233
+ Optional (mark in `peerDependenciesMeta`):
234
+
235
+ - `react@^19.2.0`, `react-dom@^19.2.0`, `react-router-dom@^7.0.0` — only consumed by `@luckystack/core/client` and the `react/*` subpath helpers. Pure server boots can skip these.
236
+ - `sonner@^2.0.0` — only needed if you wire the default sonner-backed notifier from the project's React entry; the core notifier slot itself is library-agnostic.
237
+ - `eslint@^9.0.0` — only needed to consume the shared rules from the `./eslint` subpath; pure runtime usage doesn't require it.
238
+
239
+ Runtime (bundled): `@socket.io/redis-adapter`, `dotenv`.
240
+
241
+ ## Related
242
+
243
+ - README: `packages/core/README.md`
244
+ - Architecture deep-dives: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_PACKAGING.md`, `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_MULTI_INSTANCE.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_SESSION.md`, `docs/ARCHITECTURE_AUTH.md`, `docs/ARCHITECTURE_ROUTING.md`
245
+ - Project-side glue: `config.ts`, `server/server.ts`, `server/prod/runtimeMaps.ts`, `src/_sockets/socketInitializer.ts`