@luckystack/core 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +2 -88
  4. package/dist/apiInterceptors.d.ts +36 -0
  5. package/dist/apiMethodMapRegistry.d.ts +5 -0
  6. package/dist/apiRequest.d.ts +71 -0
  7. package/dist/apiTypeStubs.d.ts +3 -5
  8. package/dist/avatarConfig.d.ts +22 -0
  9. package/dist/bindAddress.d.ts +15 -0
  10. package/dist/bootUuid.d.ts +24 -0
  11. package/dist/cancelRegistry.d.ts +7 -0
  12. package/dist/checkOrigin.d.ts +6 -0
  13. package/dist/client.d.ts +46 -335
  14. package/dist/client.js +2 -2
  15. package/dist/client.js.map +1 -1
  16. package/dist/clientHookBus.d.ts +62 -0
  17. package/dist/clients.d.ts +16 -0
  18. package/dist/config.d.ts +8 -2
  19. package/dist/configUtils.d.ts +16 -0
  20. package/dist/consoleLog.d.ts +1 -0
  21. package/dist/cookies.d.ts +28 -0
  22. package/dist/createRegistry.d.ts +39 -0
  23. package/dist/csrf.d.ts +30 -0
  24. package/dist/csrfConfig.d.ts +32 -0
  25. package/dist/db.d.ts +12 -0
  26. package/dist/dbHealthCheck.d.ts +6 -0
  27. package/dist/{config-CwW3KY9r.d.ts → deployConfigRegistry.d.ts} +8 -43
  28. package/dist/emailRegistry.d.ts +139 -0
  29. package/dist/env.d.ts +38 -0
  30. package/dist/errorFormatterRegistry.d.ts +54 -0
  31. package/dist/errorTrackerIdentity.d.ts +14 -0
  32. package/dist/errorTrackerRegistry.d.ts +85 -0
  33. package/dist/escapeHtml.d.ts +6 -0
  34. package/dist/eslint/index.d.ts +5 -8
  35. package/dist/eslint/internal/hasPackage.d.ts +1 -0
  36. package/dist/eslint/internal/ruleTypes.d.ts +4 -0
  37. package/dist/eslint/rules/no-arbitrary-tailwind-color.d.ts +3 -0
  38. package/dist/eslint/rules/no-direct-prisma-import-in-components.d.ts +3 -0
  39. package/dist/eslint/rules/no-local-socket-enumeration.d.ts +3 -0
  40. package/dist/eslint/rules/no-raw-fetch-in-src.d.ts +3 -0
  41. package/dist/eslint/rules/no-raw-try-catch.d.ts +3 -0
  42. package/dist/eslint/rules/no-unsafe-api-wrappers.d.ts +3 -0
  43. package/dist/eslint/rules/no-unsafe-sync-wrappers.d.ts +3 -0
  44. package/dist/eslint/rules/prefer-luckystack-confirm.d.ts +3 -0
  45. package/dist/eslint/rules/prefer-luckystack-dropdown.d.ts +3 -0
  46. package/dist/eslint/rules/prefer-luckystack-notify.d.ts +3 -0
  47. package/dist/extractToken.d.ts +12 -0
  48. package/dist/extractTokenFromRequest.d.ts +12 -0
  49. package/dist/getParams.d.ts +9 -0
  50. package/dist/hooks/registry.d.ts +12 -0
  51. package/dist/hooks/types.d.ts +446 -0
  52. package/dist/httpApiUtils.d.ts +54 -0
  53. package/dist/index.d.ts +110 -1886
  54. package/dist/index.js +8 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/lease.d.ts +18 -0
  57. package/dist/localSocketEnumerationGuard.d.ts +22 -0
  58. package/dist/localeReloader.d.ts +3 -0
  59. package/dist/localesRegistry.d.ts +8 -0
  60. package/dist/localizedNormalizer.d.ts +50 -0
  61. package/dist/loggerRegistry.d.ts +12 -0
  62. package/dist/middlewareRegistry.d.ts +45 -0
  63. package/dist/notifier.d.ts +17 -0
  64. package/dist/offlineQueue.d.ts +48 -0
  65. package/dist/oneTimeToken.d.ts +43 -0
  66. package/dist/pageRouteValidation.d.ts +37 -0
  67. package/dist/paths.d.ts +35 -0
  68. package/dist/peerDeps.d.ts +23 -0
  69. package/dist/portOverrideRegistry.d.ts +3 -0
  70. package/dist/processUpload.d.ts +44 -0
  71. package/dist/{sessionTypes-DanjuPaE.d.ts → projectConfig.d.ts} +32 -129
  72. package/dist/rateLimiter.d.ts +90 -0
  73. package/dist/react/AvatarProvider.d.ts +11 -0
  74. package/dist/react/Middleware.d.ts +10 -0
  75. package/dist/react/Router.d.ts +1 -0
  76. package/dist/react/TranslationProvider.d.ts +22 -0
  77. package/dist/react/notify.d.ts +12 -0
  78. package/dist/react/sessionContext.d.ts +31 -0
  79. package/dist/react/useTheme.d.ts +5 -0
  80. package/dist/redactedLogKeys.d.ts +22 -0
  81. package/dist/redis.d.ts +15 -0
  82. package/dist/redisKeyFormatter.d.ts +7 -0
  83. package/dist/resolveClientIp.d.ts +51 -0
  84. package/dist/responseNormalizer.d.ts +33 -0
  85. package/dist/roomNameFormatterRegistry.d.ts +36 -0
  86. package/dist/roomSockets.d.ts +23 -0
  87. package/dist/routedDataQuery.d.ts +7 -0
  88. package/dist/routedHttpInvocation.d.ts +20 -0
  89. package/dist/runtimeMapsRegistry.d.ts +18 -0
  90. package/dist/runtimeTypeValidation.d.ts +13 -0
  91. package/dist/secretsResolved.d.ts +3 -0
  92. package/dist/sentrySetup.d.ts +21 -0
  93. package/dist/serveAvatars.d.ts +5 -0
  94. package/dist/serviceRoute.d.ts +27 -0
  95. package/dist/servicesConfigRegistry.d.ts +22 -0
  96. package/dist/sessionProviderRegistry.d.ts +46 -0
  97. package/dist/sessionTypes.d.ts +79 -0
  98. package/dist/sleep.d.ts +1 -0
  99. package/dist/socketEvents.d.ts +37 -0
  100. package/dist/socketMiddlewareRegistry.d.ts +20 -0
  101. package/dist/socketRedisAdapter.d.ts +21 -0
  102. package/dist/socketState.d.ts +5 -0
  103. package/dist/socketStatusTypes.d.ts +6 -0
  104. package/dist/socketTypes.d.ts +34 -0
  105. package/dist/synchronizedEnvHashes.d.ts +66 -0
  106. package/dist/testFileConvention.d.ts +11 -0
  107. package/dist/testResetTargets.d.ts +5 -0
  108. package/dist/tryCatch.d.ts +1 -0
  109. package/dist/tryCatchClient.d.ts +1 -0
  110. package/dist/tryCatchSync.d.ts +1 -0
  111. package/dist/validateRequest.d.ts +50 -0
  112. package/docs/app-bootstrap.md +4 -449
  113. package/docs/config-registry.md +3 -346
  114. package/docs/csrf-config.md +3 -86
  115. package/docs/error-tracker-registry.md +3 -192
  116. package/docs/hooks.md +2 -295
  117. package/docs/rate-limit-strategy.md +3 -168
  118. package/docs/redis-adapter.md +5 -110
  119. package/docs/session-types.md +3 -178
  120. package/docs/socket-bootstrap.md +5 -385
  121. package/package.json +2 -2
  122. package/CLAUDE.md +0 -256
  123. package/dist/pageRouteValidation-B4Cmtfjv.d.ts +0 -280
@@ -1,180 +1,5 @@
1
- # Session Types
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
+ The shared session layout provides a common identity contract across authentication and transport packages; applications extend it in their own type declarations rather than modifying the framework base. A session exposed to a browser is not a safe place for credential hashes or private adapter data. See `../src/sessionTypes.ts`, `../../login/src/sessionSanitizer.ts`.
4
4
 
5
- ## Overview
6
-
7
- `BaseSessionLayout` is the structural contract every project session type extends. It lives in `@luckystack/core` (not `@luckystack/login`) so `validateRequest` can consume it without creating a circular dep: login depends on core for prisma/redis, so the session shape has to live in the lower layer. `@luckystack/login` re-exports the type for ergonomics — existing `import type { BaseSessionLayout } from '@luckystack/login'` paths keep working.
8
-
9
- `AuthProps` defines the auth gate that every API/sync handler exports (`login: true/false` + an optional `additional[]` predicate list). `validateRequest({ auth, user })` is the runtime check that turns those declarations into a `ValidationResult`.
10
-
11
- `validateInputByType` is the runtime input validator used by the api/sync handlers in dev. It lazy-loads `@luckystack/devkit` (the heavy TS compiler resolver) only when running in non-production. In production it short-circuits to success because the prod esbuild bundle marks devkit as external and the validator should not need TS to validate request bodies at request time.
12
-
13
- ## Types
14
-
15
- ```typescript
16
- export interface SessionLocation {
17
- pathName: string;
18
- searchParams?: Record<string, string>;
19
- }
20
-
21
- export interface BaseSessionLayout {
22
- id: string;
23
- token: string;
24
- email?: string | null;
25
- name?: string | null;
26
- avatar?: string | null;
27
- avatarFallback?: string | null;
28
- admin?: boolean | null;
29
- language?: string | null;
30
- location?: SessionLocation;
31
- roomCodes?: string[];
32
- csrfToken?: string;
33
- lastLogin?: Date | string | null;
34
- previousLogin?: Date | string | null;
35
- }
36
-
37
- export interface AuthProps {
38
- login: boolean;
39
- additional?: {
40
- key: keyof BaseSessionLayout;
41
- value?: unknown;
42
- type?: 'string' | 'number' | 'boolean';
43
- nullish?: boolean;
44
- mustBeFalsy?: boolean;
45
- }[];
46
- }
47
-
48
- export interface ValidationResult {
49
- status: 'success' | 'error';
50
- errorCode?: string;
51
- errorParams?: { key: string; value: string | number | boolean }[];
52
- httpStatus?: number;
53
- }
54
- ```
55
-
56
- ### Extending `BaseSessionLayout`
57
-
58
- Projects extend the type in their own `config.ts`:
59
-
60
- ```typescript
61
- import type { BaseSessionLayout } from '@luckystack/core';
62
-
63
- export interface SessionLayout extends BaseSessionLayout {
64
- organizationId?: string;
65
- role?: 'member' | 'admin' | 'owner';
66
- }
67
- ```
68
-
69
- The framework's `validateRequest` only inspects keys it knows from `BaseSessionLayout`; project-specific keys are checked via `additional[]`.
70
-
71
- ## API Reference
72
-
73
- ### `isFalsy(value: unknown): boolean`
74
-
75
- **Signature:**
76
- ```typescript
77
- export const isFalsy = (value: unknown): boolean
78
- ```
79
-
80
- **Behavior:** Returns `true` for `false`, `0`, `0n`, `''`, `null`, `undefined`, or `NaN`. Everything else is truthy.
81
-
82
- **Note:** This is a stricter "falsy" than JavaScript's coercion-based check because it ALSO treats `NaN` as falsy. Used inside `validateRequest` for the `mustBeFalsy` predicate.
83
-
84
- ### `validateRequest({ auth, user }): ValidationResult`
85
-
86
- **Signature:**
87
- ```typescript
88
- export const validateRequest = ({
89
- auth,
90
- user,
91
- }: {
92
- auth: AuthProps;
93
- user: BaseSessionLayout;
94
- }): ValidationResult
95
- ```
96
-
97
- **Behavior (in order):**
98
- 1. If `auth.additional` is undefined → returns `{ status: 'success' }` immediately. (The `login` flag is enforced by the surrounding handler, not by this function.)
99
- 2. For each `condition` in `auth.additional`:
100
- - If `condition.key` is not in `user` → returns `{ status: 'error', errorCode: 'auth.invalidCondition', errorParams: [{ key: 'key', value: condition.key }], httpStatus: 500 }`. This is a developer setup error, not a runtime auth failure.
101
- - Reads `val = user[condition.key]`. Computes `isNullish = val === null || val === undefined`.
102
- - `nullish: true` requires `isNullish`; `nullish: false` requires NOT `isNullish`. Mismatch → forbid.
103
- - `type: 'string' | 'number' | 'boolean'` requires `typeof val === <type>` (skipped when `val` is nullish). Mismatch → forbid.
104
- - `'value' in condition` requires strict equality `val === condition.value`. The membership check distinguishes "no exact-value constraint" from `value: undefined` (the latter explicitly demands `val === undefined`).
105
- - `mustBeFalsy: true` requires `isFalsy(val)`; `mustBeFalsy: false` requires `!isFalsy(val)`. Mismatch → forbid.
106
- 3. After all conditions pass → returns `{ status: 'success' }`.
107
-
108
- **Forbid shape:**
109
- ```typescript
110
- { status: 'error', errorCode: 'auth.forbidden', errorParams: [{ key: 'key', value: <key> }], httpStatus: 403 }
111
- ```
112
-
113
- **Constraint AND-semantics:** Multiple fields on a single `additional` entry are AND'd (every specified field must pass). Entries across the array are also AND'd.
114
-
115
- **Example — admin-only endpoint:**
116
- ```typescript
117
- export const auth: AuthProps = {
118
- login: true,
119
- additional: [{ key: 'admin', value: true, type: 'boolean' }],
120
- };
121
- ```
122
-
123
- **Example — verified email required:**
124
- ```typescript
125
- export const auth: AuthProps = {
126
- login: true,
127
- additional: [{ key: 'email', nullish: false, type: 'string' }],
128
- };
129
- ```
130
-
131
- ### `validateInputByType({ typeText, value, rootKey, filePath? }): Promise<ValidationResult>`
132
-
133
- **Signature:**
134
- ```typescript
135
- type ValidationResult =
136
- | { status: 'success' }
137
- | { status: 'error'; message: string };
138
-
139
- export const validateInputByType = async ({
140
- typeText,
141
- value,
142
- rootKey,
143
- filePath,
144
- }: {
145
- typeText?: string;
146
- value: unknown;
147
- rootKey: string;
148
- filePath?: string;
149
- }): Promise<ValidationResult>
150
- ```
151
-
152
- **Behavior:**
153
- 1. When `typeText` is empty, whitespace, or literal `'any'` → returns `{ status: 'success' }`.
154
- 2. When `process.env.NODE_ENV === 'production'` → returns `{ status: 'success' }` (avoids loading the TS compiler in prod).
155
- 3. Otherwise lazy-imports `@luckystack/devkit` via an indirect module ID (`const devkitModuleId: string = '@luckystack/devkit'; await import(devkitModuleId)`) so `tsc` doesn't try to resolve devkit at build time (devkit depends on core — a literal import would be a build-time circular dep).
156
- 4. Calls `devkit.resolveRuntimeTypeText({ typeText, filePath })`. On `status: 'error'` returns `{ status: 'error', message: '<rootKey>: <message>' }`.
157
- 5. Otherwise calls the internal `validateType(resolvedType.typeText, value, rootKey)` recursive validator.
158
-
159
- **Supported type forms (validateType):**
160
- - Primitives: `string`, `number`, `boolean`, `true`, `false`, `null`, `undefined`, `Date` (accepts ISO string or `Date` instance).
161
- - String literal types (single-quoted or double-quoted) and finite numeric literals, including negative, decimal, exponent, hexadecimal, binary, octal, and numeric-separator forms.
162
- - Unions (`A | B`) — succeeds on first matching branch.
163
- - Intersections (`A & B`) — requires all branches to succeed.
164
- - Arrays (`T[]`) — checks every element.
165
- - `Record<K, V>` — accepts any non-array object.
166
- - Object literals `{ key?: T; key2: T2; [index: keyof IndexKey]: T }` — checks each named field, applies index signatures for unknown keys.
167
- - `any` / `unknown` — always succeeds.
168
- - Unresolved type aliases or generics return `{ status: 'error', message: '<path>: unresolved type <name>' }`.
169
-
170
- **Edge cases:**
171
- - The `__RUNTIME_UNRESOLVED__::<message>` sentinel from devkit surfaces as `{ status: 'error', message: '<path>: <message>' }` so a missing project type doesn't crash the request.
172
- - Object validation rejects unknown keys when there is no matching index signature.
173
- - Index-signature keys can be `string`, `number`, string-literal unions, or literal types; `number` keys require the key to match `/^-?\d+(\.\d+)?$/`.
174
-
175
- ## Related
176
-
177
- - Function INDEX: `packages/core/CLAUDE.md`
178
- - Architecture: `docs/ARCHITECTURE_SESSION.md`, `docs/ARCHITECTURE_AUTH.md`, `docs/ARCHITECTURE_API.md`
179
- - README: `packages/core/README.md`
180
- - Source: `packages/core/src/sessionTypes.ts`, `validateRequest.ts`, `runtimeTypeValidation.ts`
5
+ Authorization predicates must be evaluated on server-resolved session data, not caller-supplied request fields. Missing or invalid session fields should not grant permissions; see `../src/validateRequest.ts`. Keep input validation and session authorization separate: a well-formed payload does not imply an authorized caller (`../src/runtimeTypeValidation.ts`).
@@ -1,389 +1,9 @@
1
- # Socket Bootstrap
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
+ The socket entry shares transport contracts with HTTP without conflating their security models. Keep browser-only helpers behind `/client`; the server barrel can import Node APIs. Socket handshakes must apply the origin policy and the configured session mode; do not treat a declared HTTP method as socket authorization. See `../src/client.ts`, `../src/extractToken.ts`, `../src/checkOrigin.ts`, `../../server/src/loadSocket.ts`.
4
4
 
5
- ## Overview
5
+ Cookie-backed writes require CSRF tokens, while bearer-mode callers explicitly supply credentials. CSRF tokens cached in the browser are origin-scoped so an unrelated endpoint cannot inherit them. Offline queues are a delivery convenience, not permission to replay a request without server-side auth or validation. See `../src/csrf.ts`, `../src/offlineQueue.ts` and `../../server/docs/security-defaults.md`.
6
6
 
7
- This topic covers the wiring around the LuckyStack socket transport:
7
+ **Local socket maps are unreachable by default.** The adapter's room and socket-id maps and the server's socket list know only this process's sockets, so a helper built on them looks complete on one instance and silently misses the rest of the cluster on two — a failure that surfaces only in a split deployment, where it is hardest to attribute. Three layers keep consumer code off that path (ADR 0061): a lint rule catches what is written, a cross-instance room helper makes the right path the shortest, and outside production the registered server is handed out as a guarded view that throws on the same surfaces, catching computed access no static rule sees. Looking up one socket by id stays allowed. Production returns the raw server because a throw on a hot path is worse than a missed recipient. Deliberate per-instance work (cleaning up this process's own connections, sampling its backpressure) asks for the raw server explicitly and carries a lint opt-out with a reason, so a reviewer can find it. The room helper throws when no server is registered: an empty result would be indistinguishable from an empty room. Source: `../src/roomSockets.ts`, `../src/localSocketEnumerationGuard.ts`, `../src/eslint/rules/no-local-socket-enumeration.ts`.
8
8
 
9
- - **Server-side socket slot** — `setIoInstance` / `getIoInstance` so framework code can broadcast, plus `getRoomSockets` for cross-instance room enumeration.
10
- - **Room-name formatter** — `registerRoomNameFormatter` / `formatRoomName`, and the contract a formatter must honour.
11
- - **Client-side socket singleton** — live binding `socket`, `setSocket`, `waitForSocket`, `incrementResponseIndex`.
12
- - **Token extraction** — pull the session token from a Socket.io handshake (`extractTokenFromSocket`) or a Node `IncomingMessage` (`extractTokenFromRequest`).
13
- - **Origin check** — `allowedOrigin` for CORS-style allow-listing, with `corsRejected` hook fan-out.
14
- - **Offline queue** — `enqueueApiRequest`, `enqueueSyncRequest`, flushers, and per-item `dropPolicy` overrides.
15
- - **CSRF + httpFetch** — lazy `/auth/csrf` token cache + drop-in fetch wrapper.
16
- - **Wire-protocol constants** — `socketEventNames` map + per-prefix event-name builders.
17
- - **Route parsers** — `parseServiceRouteName`, `parseTransportRouteName`.
18
- - **Cookie helpers** — `getCookieValue`, `hasCookie`.
19
- - **HTTP helpers** — `inferHttpMethod`, `getEffectiveHttpMethod`, `isMethodAllowed`.
20
-
21
- Token extraction respects `projectConfig.session.basedToken`: when `true` (token-mode) the framework prefers `socket.handshake.auth.token` / `Authorization: Bearer ...` with the cookie as fallback. When `false` (cookie-mode) the configured session cookie is read; the bearer / handshake-auth fallback is IGNORED by default (CORE-O10 — a stolen token replayed through a header can't bypass the cookie/CSRF model) unless `projectConfig.http.acceptBearerInCookieMode` is set `true` to restore the legacy cookie-then-bearer fallback.
22
-
23
- ## API Reference — IO Instance
24
-
25
- ### `setIoInstance(io: SocketIOServer | null): void`
26
-
27
- **Signature:**
28
- ```typescript
29
- export const setIoInstance = (io: SocketIOServer | null): void
30
- ```
31
-
32
- **Behavior:** Stores the running Socket.io server in a module-level slot. Framework code (`@luckystack/sync`, presence package, etc.) reads via `getIoInstance()` for broadcast paths.
33
-
34
- ### `getIoInstance(options?: { raw?: boolean })`
35
-
36
- Returns the registered instance or `null` when no server has been set. Callers must null-check.
37
-
38
- **Outside production the returned server is a guarded view.** Reading `sockets.adapter.rooms`, `sockets.adapter.sids`, or enumerating `sockets.sockets` (`values` / `keys` / `entries` / `forEach` / `size` / iteration) **throws**, with the same message the ESLint rule `luckystack/no-local-socket-enumeration` prints. `sockets.sockets.get(id)` stays allowed. Why: under the Redis adapter those maps only know the sockets of *this* process, so a helper built on them looks complete on one instance and silently misses the rest of the cluster on two — the failure surfaces only in production, where it is hardest to attribute. The guard moves that discovery to the first dev run. In production the raw instance is always returned; the guard costs nothing on the hot path.
39
-
40
- Pass `{ raw: true }` for deliberate per-instance work — a sweep cleaning up this process's own connections, sampling this process's backpressure. The option is the audit trail: a reviewer can grep for it. Framework-internal code and the Redis adapter always hold the raw instance.
41
-
42
- For "who is in this room" use `getRoomSockets` below, or `io.in(room).fetchSockets()` when you already hold the physical room name.
43
-
44
- ### `getRoomSockets(room, { userId? })`
45
-
46
- Cross-instance room enumeration. Routes `room` through the room-name formatter under the canonical `'broadcast'` purpose (`userId` defaults to `null`, and is context only — see the formatter contract below), then asks the adapter for every member on every instance and returns the `RemoteSocket[]`. `'all'` returns every socket everywhere.
47
-
48
- It **throws** when no Socket.io server is registered. A silent `[]` in that situation is indistinguishable from "the room is empty", which is exactly the class of failure this helper exists to prevent.
49
-
50
- ## API Reference — Room-Name Formatter
51
-
52
- ### `registerRoomNameFormatter(fn)` / `getRoomNameFormatter()` / `formatRoomName(raw, ctx)` / `defaultRoomNameFormatter`
53
-
54
- Every room name the framework joins, leaves, broadcasts to or uses for presence passes through the registered formatter (default: identity) with a `RoomNameFormatterContext` — a `purpose` (`'join'`, `'leave'`, `'broadcast'`, `'presence'`) and the `userId` of whoever performs the operation. The intended use is a stable, user-independent transform such as per-tenant prefixing.
55
-
56
- **The contract: `userId` is context, never an input to the physical name of a content room.** For `purpose: 'broadcast'` the framework passes whichever user performs the operation — the joiner on join and rejoin, the sender on fan-out, the originator on `broadcastStream` — so for a shared room it is a *different* value on each side. A formatter that folds `userId` into the name makes join and fan-out land in different physical rooms, and every broadcast then reaches nobody: no error, `sync.noReceiversFound` at best, silence at worst. The framework cannot detect this for you, because from its side both names are just strings.
57
-
58
- Per-user isolation belongs in the raw room code the consumer chooses (put the user in the *code*, not in the formatter), or in the separate `'presence'` family, which is per-user by design.
59
-
60
- ## API Reference — Client Socket Singleton
61
-
62
- ### `socket: Socket | null`
63
-
64
- A live `export let` binding — importers always see the current value after `setSocket(...)` replaces it.
65
-
66
- ### `setSocket(next: Socket | null): void`
67
-
68
- Stores the project's socket.io-client instance. Called from `src/_sockets/socketInitializer.ts`.
69
-
70
- ### `incrementResponseIndex(): number`
71
-
72
- **Returns:** The next monotonic response index (used to build per-request reply event names like `apiResponse-42`).
73
-
74
- **Behavior:**
75
- - Module-scoped counter, increments before returning so the first call returns `1`.
76
- - Used by `apiRequest` / `syncRequest` clients to disambiguate concurrent requests on the same socket.
77
-
78
- ### `waitForSocket(): Promise<Socket | null>`
79
-
80
- **Behavior:**
81
- - Polls every 10 ms until `socket !== null` or 500 iterations elapse (~5 seconds).
82
- - Returns the socket on success, `null` on timeout.
83
-
84
- **Example:**
85
- ```typescript
86
- import { waitForSocket } from '@luckystack/core/client';
87
-
88
- const s = await waitForSocket();
89
- if (!s) throw new Error('socket never connected');
90
- ```
91
-
92
- ## API Reference — Custom Socket Middlewares
93
-
94
- The framework lets consumers wedge their own Socket.io `io.use(...)` middlewares into the bootstrap without forking `loadSocket.ts`. Registered middlewares run in registration order, before any `connect` handler — same contract as a direct `io.use(...)` call.
95
-
96
- ### `registerSocketMiddleware(mw: SocketMiddleware): void`
97
-
98
- **Signature:**
99
- ```typescript
100
- export type SocketMiddleware = (socket: Socket, next: (err?: Error) => void) => void;
101
- export const registerSocketMiddleware = (mw: SocketMiddleware): void
102
- ```
103
-
104
- **Behavior:** Appends the middleware to a module-level list. `@luckystack/server`'s `loadSocket` calls `applySocketMiddlewares(io)` after constructing the `SocketIOServer` and before attaching the `connect` handler, so every registered middleware runs on the handshake of every incoming socket.
105
-
106
- **Example — license-key gate:**
107
- ```typescript
108
- import { registerSocketMiddleware } from '@luckystack/core';
109
-
110
- registerSocketMiddleware((socket, next) => {
111
- const licenseKey = socket.handshake.auth.licenseKey;
112
- if (typeof licenseKey !== 'string' || !isValidLicense(licenseKey)) {
113
- return next(new Error('invalid license'));
114
- }
115
- next();
116
- });
117
- ```
118
-
119
- **Example — observability tag (Datadog APM):**
120
- ```typescript
121
- import { registerSocketMiddleware } from '@luckystack/core';
122
- import tracer from 'dd-trace';
123
-
124
- registerSocketMiddleware((socket, next) => {
125
- const span = tracer.scope().active();
126
- if (span) span.setTag('socket.id', socket.id);
127
- next();
128
- });
129
- ```
130
-
131
- ### `getSocketMiddlewares(): readonly SocketMiddleware[]`
132
-
133
- Read the registered list (frozen view). Useful for diagnostics.
134
-
135
- ### `clearSocketMiddlewares(): void`
136
-
137
- Drops every registered middleware. Test/hot-reload helper.
138
-
139
- ### `applySocketMiddlewares(io: SocketIOServer): void`
140
-
141
- Framework-internal — wires every registered middleware via `io.use(...)`. Called by `@luckystack/server`'s `loadSocket`; consumers should not call this directly.
142
-
143
- ## API Reference — Token Extraction
144
-
145
- ### `extractTokenFromSocket(socket: Socket): string | null`
146
-
147
- **Signature:**
148
- ```typescript
149
- export const extractTokenFromSocket = (socket: Socket): string | null
150
- ```
151
-
152
- **Behavior:**
153
- - Reads `socket.handshake.auth.token` (when it is a string) and the configured cookie (`projectConfig.http.sessionCookieName`) from `socket.handshake.headers.cookie`.
154
- - When `session.basedToken === true`: returns the auth-token first, falls back to the cookie.
155
- - When `session.basedToken === false`: returns only the cookie by default; when `http.acceptBearerInCookieMode === true`, falls back to the auth-token for legacy compatibility.
156
-
157
- **Returns:** The token string or `null`.
158
-
159
- ### `extractTokenFromRequest(req: IncomingMessage): string | null`
160
-
161
- **Signature:**
162
- ```typescript
163
- export const extractTokenFromRequest = (req: IncomingMessage): string | null
164
- ```
165
-
166
- **Behavior:**
167
- - Reads `req.headers.authorization` and extracts the bearer (`'Bearer <token>'`).
168
- - Reads the configured cookie from `req.headers.cookie`.
169
- - 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.
170
-
171
- ## API Reference — Origin Check
172
-
173
- ### `allowedOrigin(origin: string): boolean`
174
-
175
- **Signature:**
176
- ```typescript
177
- const allowedOrigin = (origin: string): boolean
178
- export default allowedOrigin
179
- ```
180
-
181
- **Behavior (in execution order):**
182
- 1. Normalizes the origin to `scheme://host[:port]`, strips default `:80` / `:443`, lowercases.
183
- 2. Computes the same-origin bind location from `getBindAddress()` and the `SECURE` env var.
184
- 3. When `cors.allowLocalhost` and the origin matches `/^https?:\/\/localhost(:\d+)?$/i` → returns `true`.
185
- 4. When `cors.allowedOrigins` is a function → calls it; if it returns true (or the origin equals the bind location) → returns `true`.
186
- 5. When `cors.allowedOrigins` is a string array → builds a normalized allowed set (bind location + configured list); returns `true` on match.
187
- 6. Otherwise: dispatches the `corsRejected` hook (with origin + normalized origin + allowedOrigins + allowLocalhost) and returns `false`. Also logs via `getLogger().warn` when `logging.devLogs` is on.
188
-
189
- **Edge cases:**
190
- - An empty `origin` is treated as not-allowed.
191
- - The resolver-function form is sync only — async predicates are not supported because Socket.io's CORS check is sync.
192
-
193
- ## API Reference — Offline Queue
194
-
195
- Per-queue items:
196
- ```typescript
197
- interface QueueItem {
198
- id: string;
199
- key: string;
200
- run: (socketInstance: Socket) => void;
201
- createdAt: number;
202
- dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
203
- }
204
- ```
205
-
206
- ### `isOnline(): boolean`
207
- Returns `true` in non-browser environments. In browsers, reads `navigator.onLine`.
208
-
209
- ### `enqueueApiRequest(item): boolean` / `enqueueSyncRequest(item): boolean`
210
-
211
- **Behavior:**
212
- 1. Evicts items older than `offlineQueue.maxAgeMs` from the relevant queue.
213
- 2. If `queue.length < offlineQueue.maxSize`, pushes the new item and returns `true`.
214
- 3. Otherwise applies the effective drop policy (per-item `dropPolicy` overrides the global default):
215
- - `'drop-oldest'` — shift + push, returns `true` (logs debug).
216
- - `'drop-newest'` — keep existing queue, returns `false` (logs debug).
217
- - `'reject'` — returns `false` (logs warn).
218
-
219
- ### `removeApiQueueItem(id)` / `removeSyncQueueItem(id)`
220
- Removes a single item by `id`. No-op if not found.
221
-
222
- ### `removeApiQueueItemsByKey(key)`
223
- Removes every API queue item matching `item.key`. Used to coalesce duplicate requests.
224
-
225
- ### `flushApiQueue(canRun, socketInstance)` / `flushSyncQueue(canRun, socketInstance)`
226
-
227
- **Behavior:**
228
- - Re-entrancy guard prevents nested flushes for the same queue.
229
- - Evicts expired items.
230
- - Drains FIFO while `canRun()` returns `true`, calling each item's `run(socketInstance)`.
231
-
232
- ### `getApiQueueSize(): number` / `getSyncQueueSize(): number`
233
-
234
- Read-only sizes (after eviction triggers from enqueue/flush; reads alone do not evict).
235
-
236
- **Example:**
237
- ```typescript
238
- import { enqueueApiRequest } from '@luckystack/core/client';
239
-
240
- enqueueApiRequest({
241
- id: requestId,
242
- key: `${routeName}:${userId}`,
243
- createdAt: Date.now(),
244
- dropPolicy: 'drop-newest',
245
- run: (s) => s.emit('apiRequest', message),
246
- });
247
- ```
248
-
249
- ## API Reference — CSRF + httpFetch
250
-
251
- ### `getCsrfToken(): Promise<string | null>`
252
-
253
- **Behavior:**
254
- - Returns `null` immediately in token-mode sessions (`session.basedToken === true`) — CSRF is unnecessary because cross-origin requests don't auto-attach sessionStorage.
255
- - Returns the cached token if previously fetched.
256
- - Otherwise fetches `/auth/csrf` with `credentials: 'include'`, caches and returns `body.csrfToken`.
257
- - Inflight de-duplication: concurrent callers share the same promise.
258
-
259
- ### `clearCsrfToken(): void`
260
-
261
- Drops the cached token + any inflight fetch. Call on logout or when a 403 `auth.csrfMismatch` is observed.
262
-
263
- ### `httpFetch(input, init?): Promise<Response>`
264
-
265
- Drop-in `fetch` replacement that:
266
- - Forces `credentials: 'include'` unless overridden.
267
- - Adds `Content-Type: application/json` when a string body is present and no content-type was set.
268
- - For state-changing methods (`POST`, `PUT`, `PATCH`, `DELETE`) in cookie mode: lazily fetches the CSRF token, attaches as `x-csrf-token`. On a 403 `auth.csrfMismatch` response, clears the cache and retries once with a refreshed token.
269
-
270
- **Example:**
271
- ```typescript
272
- import { httpFetch } from '@luckystack/core/client';
273
-
274
- const res = await httpFetch('/api/system/myRoute/v1', {
275
- method: 'POST',
276
- body: JSON.stringify({ foo: 1 }),
277
- });
278
- ```
279
-
280
- ## API Reference — Wire Protocol Constants
281
-
282
- ### `socketEventNames`
283
-
284
- A frozen record of event names:
285
-
286
- | Key | Value |
287
- |---|---|
288
- | `apiRequest` | `'apiRequest'` |
289
- | `sync` | `'sync'` |
290
- | `joinRoom` / `leaveRoom` / `getJoinedRooms` | (same literal) |
291
- | `updateLocation` / `updateSession` / `sessionReplaced` / `logout` | (same literal) |
292
- | `intentionalDisconnect` / `intentionalReconnect` / `userAfk` / `userBack` | (same literal) |
293
- | `connect` / `disconnect` / `reconnectAttempt` / `connectError` | underlying socket.io events |
294
- | `apiResponsePrefix` | `'apiResponse-'` |
295
- | `apiStreamPrefix` | `'apiStream-'` |
296
- | `syncResponsePrefix` | `'sync-'` |
297
- | `syncProgressPrefix` | `'sync-progress-'` |
298
- | `joinRoomResponsePrefix` / `leaveRoomResponsePrefix` / `getJoinedRoomsResponsePrefix` | (same prefix) |
299
-
300
- ### Event name builders
301
-
302
- | Function | Behavior |
303
- |---|---|
304
- | `buildApiResponseEventName(idx)` | `'apiResponse-<idx>'` |
305
- | `buildApiStreamEventName(idx)` | `'apiStream-<idx>'` |
306
- | `buildSyncResponseEventName(idx)` | `'sync-<idx>'` |
307
- | `buildSyncProgressEventName(idx)` | `'sync-progress-<idx>'` |
308
- | `buildJoinRoomResponseEventName(idx)` | `'joinRoom-<idx>'` |
309
- | `buildLeaveRoomResponseEventName(idx)` | `'leaveRoom-<idx>'` |
310
- | `buildGetJoinedRoomsResponseEventName(idx)` | `'getJoinedRooms-<idx>'` |
311
-
312
- ## API Reference — Service Route Parsers
313
-
314
- ### `parseServiceRouteName(value: string): ServiceRouteParseResult`
315
-
316
- Validates `<service>/<routeName...>` strings.
317
-
318
- **Returns:**
319
- - `{ status: 'success', normalizedRouteName, service, routeName }` — when at least two non-empty segments are present.
320
- - `{ status: 'error', reason }` — for empty input, empty segments, or fewer than two segments.
321
-
322
- Normalization rules: trim, replace `\` with `/`, strip leading/trailing slashes.
323
-
324
- ### `parseTransportRouteName({ value, prefix })`
325
-
326
- Validates a full transport route like `'api/system/foo/v1'`.
327
-
328
- **Parameters:**
329
- | Name | Type | Required | Description |
330
- |---|---|---|---|
331
- | `value` | `string` | yes | Raw route string. |
332
- | `prefix` | `'api' \| 'sync'` | yes | Determines whether the leading `'api'`/`'sync'` segment is stripped. |
333
-
334
- **Returns:**
335
- - `{ status: 'success', normalizedFullName, version, serviceRoute }` — when the trailing segment matches `/^v\d+$/` and the leading service/route part parses cleanly.
336
- - `{ status: 'error', reason }` — on length / version / segment violations.
337
-
338
- ## API Reference — Cookies
339
-
340
- ### `getCookieValue(cookieHeader: string | undefined, cookieName: string): string | null`
341
-
342
- Regex-escapes `cookieName`, searches for `(?:^|;\s*)<name>=<value>` in the header, URL-decodes the captured value (falls back to raw on decode failure). Returns `null` when input is missing or no match.
343
-
344
- ### `hasCookie(cookieHeader, cookieName): boolean`
345
-
346
- Convenience wrapper — true when `getCookieValue` returns a non-null value.
347
-
348
- ## API Reference — HTTP Method Inference
349
-
350
- ### `inferHttpMethod(apiName: string): HttpMethod`
351
-
352
- Naming-convention heuristic (used as fallback when no `apiMethodMap` is registered):
353
- - starts with `get` / `fetch` / `list` → `GET`
354
- - starts with `delete` / `remove` → `DELETE`
355
- - starts with `update` / `edit` / `patch` → `PUT`
356
- - otherwise → `POST`
357
-
358
- Strips a trailing `v\d+` segment before applying the rule, so `api/examples/getUser/v1` resolves to `'GET'`.
359
-
360
- ### `getEffectiveHttpMethod(apiConfig, apiName)`
361
-
362
- Prefers an explicit `apiConfig.httpMethod`; otherwise calls `inferHttpMethod(apiName)`.
363
-
364
- ### `isMethodAllowed(requestMethod, allowedMethod): boolean`
365
-
366
- Returns `true` when the request method matches the allowed method or is `'OPTIONS'` (CORS preflight passthrough).
367
-
368
- ## Hooks dispatched
369
-
370
- | Hook name | Payload type | When |
371
- |---|---|---|
372
- | `corsRejected` | `CorsRejectedPayload` | `allowedOrigin` is about to return `false`. |
373
-
374
- ## Config keys consumed
375
-
376
- | Key | Where |
377
- |---|---|
378
- | `session.basedToken` | token extractors, `httpFetch`, `getCsrfToken` |
379
- | `http.sessionCookieName` | token extractors |
380
- | `http.cors.allowedOrigins`, `http.cors.allowLocalhost` | `allowedOrigin` |
381
- | `offlineQueue.maxSize`, `maxAgeMs`, `dropPolicy` | offline queue |
382
- | `logging.devLogs` | `allowedOrigin` warn-logs |
383
-
384
- ## Related
385
-
386
- - Function INDEX: `packages/core/CLAUDE.md`
387
- - Architecture: `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_AUTH.md`
388
- - README: `packages/core/README.md`
389
- - Source: `packages/core/src/socketTypes.ts`, `socketState.ts`, `socketEvents.ts`, `extractToken.ts`, `extractTokenFromRequest.ts`, `checkOrigin.ts`, `offlineQueue.ts`, `csrf.ts`, `cookies.ts`, `httpApiUtils.ts`, `serviceRoute.ts`
9
+ **A room-name formatter receives `userId` as context, never as a name part.** Every framework join, leave, broadcast and presence room passes through the registered formatter, intended for a stable, user-independent transform such as per-tenant prefixing. For a broadcast the framework passes whoever performs the operation — the joiner on join, the sender on fan-out, the originator of a stream — so for a shared room the value differs per side. A formatter that folds it into a content room's physical name puts join and fan-out in different rooms, and every broadcast silently reaches nobody; the framework cannot detect this because both names are just strings. Put per-user isolation in the raw room code the consumer chooses, or in the presence family, which is per-user by design (ADR 0060). Source: `../src/roomNameFormatterRegistry.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/core",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -60,7 +60,7 @@
60
60
  "files": [
61
61
  "dist",
62
62
  "README.md",
63
- "CLAUDE.md",
63
+ "AGENTS.md",
64
64
  "docs",
65
65
  "LICENSE",
66
66
  "CHANGELOG.md"