@luckystack/core 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +148 -107
  2. package/CLAUDE.md +245 -245
  3. package/LICENSE +21 -21
  4. package/README.md +93 -93
  5. package/dist/{chunk-WFABOO6J.js → chunk-AFG7KKMS.js} +3 -3
  6. package/dist/chunk-AFG7KKMS.js.map +1 -0
  7. package/dist/{chunk-3AWP2YEA.js → chunk-J3UOWYT4.js} +2 -28
  8. package/dist/chunk-J3UOWYT4.js.map +1 -0
  9. package/dist/{chunk-LU3IPFTR.js → chunk-LTCDQU5Z.js} +1 -1
  10. package/dist/chunk-LTCDQU5Z.js.map +1 -0
  11. package/dist/chunk-R7IRJFVC.js +67 -0
  12. package/dist/chunk-R7IRJFVC.js.map +1 -0
  13. package/dist/client.d.ts +3 -2
  14. package/dist/client.js +4 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/config-DZCzBNwP.d.ts +122 -0
  17. package/dist/config.d.ts +2 -0
  18. package/dist/config.js +31 -0
  19. package/dist/eslint/index.js.map +1 -1
  20. package/dist/index.d.ts +21 -149
  21. package/dist/index.js +66 -70
  22. package/dist/index.js.map +1 -1
  23. package/dist/pageRouteValidation-sJJCX-YV.d.ts +270 -0
  24. package/dist/{projectConfig-XL5VB76A.js → projectConfig-DBLSQ3NY.js} +2 -2
  25. package/dist/{sentrySetup-4NTWLN2A.js → sentrySetup-MF6JSVWA.js} +3 -3
  26. package/dist/sentrySetup-MF6JSVWA.js.map +1 -0
  27. package/dist/{pageRouteValidation-gqktrJci.d.ts → sessionTypes-Dzen_rPK.d.ts} +23 -265
  28. package/docs/app-bootstrap.md +418 -418
  29. package/docs/config-registry.md +348 -348
  30. package/docs/csrf-config.md +88 -88
  31. package/docs/error-tracker-registry.md +194 -174
  32. package/docs/hooks.md +298 -298
  33. package/docs/rate-limit-strategy.md +170 -170
  34. package/docs/redis-adapter.md +111 -111
  35. package/docs/session-types.md +180 -180
  36. package/docs/socket-bootstrap.md +366 -366
  37. package/package.json +6 -2
  38. package/dist/chunk-3AWP2YEA.js.map +0 -1
  39. package/dist/chunk-LU3IPFTR.js.map +0 -1
  40. package/dist/chunk-WFABOO6J.js.map +0 -1
  41. /package/dist/{projectConfig-XL5VB76A.js.map → config.js.map} +0 -0
  42. /package/dist/{sentrySetup-4NTWLN2A.js.map → projectConfig-DBLSQ3NY.js.map} +0 -0
package/docs/hooks.md CHANGED
@@ -1,298 +1,298 @@
1
- # Hooks
2
-
3
- > Deep specs for the async + sync hook registries, the canonical core-owned payload shapes, `processUpload`, and the response normalizer family. Source: `packages/core/src/hooks/`, `processUpload.ts`, `responseNormalizer.ts`. Bijgewerkt: 2026-05-20.
4
-
5
- ## Overview
6
-
7
- LuckyStack ships two hook registries:
8
-
9
- - **Async hook registry** (`registerHook` / `dispatchHook`) — used by lifecycle events that can tolerate await (`preApiExecute`, `postApiRespond`, `corsRejected`, etc.). Handlers can return a `HookStopSignal` to abort the framework's main flow.
10
- - **Sync mutator registry** (`registerSyncHook` / `dispatchSyncHook`) — used by hot-path mutators that must remain synchronous (`preErrorNormalize`, `postErrorNormalize`). Handlers mutate the payload object in place; there is no stop signal.
11
-
12
- Both registries swallow handler exceptions: errors are routed to `getLogger().error` + `captureException` so a misbehaving handler can never crash the framework. Multiple handlers per hook are supported and invoked in registration order.
13
-
14
- Feature packages extend `HookPayloads` via TypeScript module augmentation. The augmentation file must be in a tsconfig `include` path AND side-effect imported from the package barrel (`import './hookPayloads';`) so TS merges the declarations.
15
-
16
- `processUpload` is included in this topic because it's the public API that ties the `onUploadStart` / `onUploadComplete` hook contract together for consumer upload routes.
17
-
18
- ## Lifecycle flow (api request)
19
-
20
- ```
21
- client emits apiRequest
22
- ├─> preHttpRequest (async — read-only meta)
23
- ├─> preApiValidate (async — can stop)
24
- ├─> [framework validation]
25
- ├─> postApiValidate (async — read-only)
26
- ├─> preApiExecute (async — can stop)
27
- ├─> [handler.main()]
28
- ├─> postApiExecute (async — read-only + durationMs)
29
- ├─> preApiRespond (async — mutate response in place)
30
- ├─> transformApiResponse (async — mutate response in place)
31
- └─> postApiRespond (async — read-only, after wire-send)
32
-
33
- On error:
34
- ├─> preErrorNormalize (sync mutator)
35
- ├─> [responseNormalizer]
36
- ├─> postErrorNormalize (sync mutator)
37
- └─> apiError (async — observability)
38
- ```
39
-
40
- The sync flow has its own analogues (`preSyncAuthorize`, `preSyncFanout`, `postSyncFanout`, `preSyncStream`, `postSyncStream`, `syncError`).
41
-
42
- ## Types
43
-
44
- ```typescript
45
- export interface HookSessionShape {
46
- id: string;
47
- token: string;
48
- email?: string | null;
49
- name?: string | null;
50
- avatar?: string | null;
51
- avatarFallback?: string | null;
52
- admin?: boolean | null;
53
- language?: string | null;
54
- roomCodes?: string[];
55
- }
56
-
57
- export interface HookStopSignal {
58
- stop: true;
59
- errorCode: string;
60
- httpStatus?: number;
61
- }
62
-
63
- export type HookResult = undefined | HookStopSignal;
64
- export type HookHandler<TPayload> = (payload: TPayload) => Promise<HookResult> | HookResult;
65
- export type SyncHookHandler<TPayload> = (payload: TPayload) => void;
66
-
67
- export type DispatchResult =
68
- | { stopped: false }
69
- | { stopped: true; signal: HookStopSignal };
70
-
71
- export type HookName = keyof HookPayloads;
72
- export type SyncHookName = keyof SyncHookPayloads;
73
- ```
74
-
75
- `HookSessionShape` is defined in core (not imported from login) so the core hook layer stays free of feature-package deps. Any concrete `BaseSessionLayout` / project `SessionLayout` is structurally assignable.
76
-
77
- ## API Reference — Async Hooks
78
-
79
- ### `registerHook<TName>(name, handler): void`
80
-
81
- **Signature:**
82
- ```typescript
83
- export const registerHook = <TName extends HookName>(
84
- name: TName,
85
- handler: HookHandler<HookPayloads[TName]>,
86
- ): void
87
- ```
88
-
89
- **Behavior:** Appends the handler to the (string-keyed) list of handlers for `name`. Multiple registrations are allowed; order is preserved.
90
-
91
- ### `dispatchHook<TName>(name, payload): Promise<DispatchResult>`
92
-
93
- **Signature:**
94
- ```typescript
95
- export const dispatchHook = async <TName extends HookName>(
96
- name: TName,
97
- payload: HookPayloads[TName],
98
- ): Promise<DispatchResult>
99
- ```
100
-
101
- **Behavior (in order):**
102
- 1. Reads the handler list for `name` (empty array if none).
103
- 2. For each handler:
104
- - Awaits `handler(payload)` inside a try/catch.
105
- - On thrown error → logs `hook: handler for "<name>" threw` via `getLogger().error` + `captureException(error, { hook: name })`. Continues with the next handler.
106
- - On returned `HookStopSignal` (any non-undefined return value) → returns `{ stopped: true, signal: result }` immediately. Remaining handlers do NOT run.
107
- 3. After all handlers complete without a stop → returns `{ stopped: false }`.
108
-
109
- **Mutation contract:** Handlers receive the same payload object reference. For payloads that explicitly support mutation (`PreApiRespondPayload.response`, `transformApiResponse`), mutating in place is the documented mechanism to replace the outgoing response without a stop signal.
110
-
111
- ### `clearAllHooks(): void`
112
-
113
- Test-only. Clears both the async and sync hook registries. Never call from production paths — framework-internal hooks (presence cleanup, etc.) would be wiped.
114
-
115
- ## API Reference — Sync Hooks
116
-
117
- ### `registerSyncHook<TName>(name, handler): void`
118
-
119
- **Behavior:** Same shape as `registerHook` but appends to the sync map.
120
-
121
- ### `dispatchSyncHook<TName>(name, payload): void`
122
-
123
- **Behavior:**
124
- - Invokes every handler synchronously.
125
- - Per-handler try/catch isolates failures (same logger + `captureException` path as async).
126
- - Cannot stop the flow. Handlers mutate `payload` in place.
127
-
128
- **When to use sync:** Only when the call site is on a hot path that genuinely cannot afford to `await`. The current usage is `normalizeErrorResponse` because it's invoked from many places in a single request (api error path, sync error path, transport boundaries).
129
-
130
- ## Core-owned payload shapes
131
-
132
- ### API lifecycle
133
-
134
- | Hook | Payload (key fields) |
135
- |---|---|
136
- | `preHttpRequest` | `method`, `url`, `requestId`, `origin`, `headers` (sanitized — no auth/cookie). |
137
- | `preApiValidate` | `routeName`, `data`, `user`. |
138
- | `postApiValidate` | extends `preApiValidate` + `validation: { status: 'success' } \| { status: 'error', message }`. |
139
- | `preApiExecute` | `routeName`, `data`, `user`. |
140
- | `postApiExecute` | `routeName`, `data`, `user`, `result`, `error`, `durationMs`. |
141
- | `preApiRespond` | `routeName`, `user`, `response: ApiResponseEnvelope` (mutable). |
142
- | `transformApiResponse` | Same shape — separate hook so plugins can compose without ordering with `preApiRespond`. |
143
- | `postApiRespond` | `routeName`, `user`, `response` (immutable after wire-send). |
144
-
145
- ### Sync lifecycle
146
-
147
- | Hook | Payload (key fields) |
148
- |---|---|
149
- | `preSyncAuthorize` | `routeName`, `data` (raw), `user`, `receiver`. |
150
- | `preSyncFanout` | `routeName`, `data`, `user`, `receiver`, `serverOutput`. |
151
- | `postSyncFanout` | extends `preSyncFanout` + `recipientCount`. |
152
- | `preSyncStream` | `routeName`, `chunk`, `recipient`. |
153
- | `postSyncStream` | extends `preSyncStream` + `chunkIndex`. |
154
-
155
- ### Error / security signals
156
-
157
- | Hook | Payload (key fields) |
158
- |---|---|
159
- | `apiError` | `route`, `method?`, `requestId?`, `user?`, `error`. |
160
- | `syncError` | Same shape. |
161
- | `rateLimitExceeded` | `scope`, `key` (sanitized), `limit`, `windowMs`, `count`, `route?`, `ip?`, `userId?`. |
162
- | `corsRejected` | `origin`, `normalizedOrigin`, `allowedOrigins`, `allowLocalhost`, `route?`. |
163
- | `csrfMismatch` | `route`, `method?`, `requestId?`, `userId?`, `providedToken: boolean`. |
164
-
165
- ### Session refresh
166
-
167
- | Hook | Payload (key fields) |
168
- |---|---|
169
- | `preSessionRefresh` | `token`, `userId`, `oldTtl`, `newTtl`. |
170
- | `postSessionRefresh` | extends + `applied: boolean`. |
171
-
172
- ### Uploads
173
-
174
- | Hook | Payload (key fields) |
175
- |---|---|
176
- | `onUploadStart` | `userId`, `contentType`, `sizeBytes`, `uploadKind`. Can return `HookStopSignal` (e.g. content moderation reject). |
177
- | `onUploadComplete` | `userId`, `fileName`, `sizeBytes`, `uploadKind`. Read-only — fires after disk write. |
178
-
179
- ### Sync mutator hooks
180
-
181
- | Hook | Payload (key fields) |
182
- |---|---|
183
- | `preErrorNormalize` | `response: ErrorResponseInput` (mutable), `preferredLocale?`, `userLanguage?`, `fallbackHttpStatus?`. |
184
- | `postErrorNormalize` | `normalized: NormalizedErrorResponse` (mutable), `preferredLocale?`, `userLanguage?`. |
185
-
186
- ## API Reference — processUpload
187
-
188
- ### `processUpload(input: ProcessUploadInput): Promise<ProcessUploadResult>`
189
-
190
- **Signature:**
191
- ```typescript
192
- export interface ProcessUploadInput {
193
- userId: string;
194
- contentType: string;
195
- buffer: Buffer;
196
- uploadKind?: string; // default 'avatar'
197
- fileName: string;
198
- encodeAndSave: (buffer: Buffer) => Promise<number>;
199
- }
200
-
201
- export type ProcessUploadResult =
202
- | { status: 'success'; sizeBytes: number }
203
- | { status: 'rejected'; errorCode: string }
204
- | { status: 'error'; reason: string; cause?: unknown };
205
- ```
206
-
207
- **Behavior (in order):**
208
- 1. Resolves `uploadKind` (defaults to `'avatar'`).
209
- 2. Dispatches `onUploadStart` with `{ userId, contentType, sizeBytes: buffer.byteLength, uploadKind }`. If a handler returned a stop signal → returns `{ status: 'rejected', errorCode: signal.errorCode }`.
210
- 3. Runs `input.encodeAndSave(buffer)` inside `tryCatch`. On error → returns `{ status: 'error', reason: error.message || 'encode-failed', cause: error }`.
211
- 4. Dispatches `onUploadComplete` with `{ userId, fileName, sizeBytes: finalSize ?? buffer.byteLength, uploadKind }`.
212
- 5. Returns `{ status: 'success', sizeBytes }`.
213
-
214
- **Why a callback (not built-in encoder):** core stays free of heavy native deps (sharp, ffmpeg, S3 SDKs). The consumer plugs in whichever encoder it needs and core only brackets the call with the hook contract.
215
-
216
- **Example:** see `src/settings/_api/updateUser_v1.ts` for the canonical avatar usage.
217
-
218
- ## API Reference — Response normalizer family
219
-
220
- ### Types
221
- ```typescript
222
- export interface ErrorParam {
223
- key: string;
224
- value: string | number | boolean;
225
- }
226
-
227
- export interface ErrorResponseInput {
228
- status?: unknown;
229
- errorCode?: unknown;
230
- errorParams?: unknown;
231
- httpStatus?: unknown;
232
- }
233
-
234
- export interface NormalizedErrorResponse {
235
- status: 'error';
236
- message: string;
237
- errorCode: string;
238
- errorParams?: ErrorParam[];
239
- httpStatus?: number;
240
- }
241
-
242
- export const INVALID_ERROR_RESPONSE_CODE = 'error.invalidResponse';
243
- ```
244
-
245
- ### `isErrorParamArray(value: unknown): value is ErrorParam[]`
246
-
247
- Type guard. Returns `true` when `value` is an array and every element has a string `key` plus a `string | number | boolean` `value`.
248
-
249
- ### `normalizeErrorResponseCore({ response, fallbackHttpStatus?, fallbackErrorCode?, resolveMessage? })`
250
-
251
- **Behavior:**
252
- - Extracts `errorParams` (defaults to `undefined` when not an `ErrorParam[]`).
253
- - Reads `errorCode` (trimmed string). Falls back to `fallbackErrorCode` then `INVALID_ERROR_RESPONSE_CODE`.
254
- - Reads `httpStatus` only when `typeof === 'number'`; otherwise uses `fallbackHttpStatus`.
255
- - Calls `resolveMessage?.({ errorCode, errorParams })` when provided; otherwise uses the raw `errorCode` as `message`.
256
- - Returns `{ status: 'error', message, errorCode, errorParams?, httpStatus? }`.
257
-
258
- ### `defaultHttpStatusForResponse({ status, explicitHttpStatus?, fallbackErrorStatus = 400 })`
259
-
260
- Returns `explicitHttpStatus` when numeric, else `200` for `'success'` and `fallbackErrorStatus` for `'error'`.
261
-
262
- The localized `normalizeErrorResponse` wrapper (in `localizedNormalizer.ts`) layers i18n + the `preErrorNormalize` / `postErrorNormalize` sync hooks on top of `normalizeErrorResponseCore` — see source for the full surface.
263
-
264
- ## Extending HookPayloads (feature packages)
265
-
266
- ```typescript
267
- // packages/<feature>/src/hookPayloads.ts
268
- import type { HookSessionShape } from '@luckystack/core';
269
-
270
- declare module '@luckystack/core' {
271
- interface HookPayloads {
272
- myFeatureHook: { userId: string; data: { foo: number } };
273
- }
274
- }
275
-
276
- export {};
277
- ```
278
-
279
- Then side-effect import from the package barrel so the augmentation is reachable:
280
-
281
- ```typescript
282
- // packages/<feature>/src/index.ts
283
- import './hookPayloads';
284
- export { /* ... */ };
285
- ```
286
-
287
- ## Edge cases
288
-
289
- - A handler that returns `undefined` continues the chain; ANY non-`undefined` return value is treated as a stop signal (the type is `undefined | HookStopSignal`, so returning a plain string would be a type error in TS but a stop in JS — keep the typed signature).
290
- - `clearAllHooks` clears BOTH registries; if you only need to drop async handlers, register fresh sync ones afterwards.
291
- - `dispatchSyncHook` ignores handler return values entirely — mutate `payload` in place.
292
-
293
- ## Related
294
-
295
- - Function INDEX: `packages/core/CLAUDE.md`
296
- - Architecture: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`
297
- - README: `packages/core/README.md`
298
- - Source: `packages/core/src/hooks/registry.ts`, `hooks/types.ts`, `processUpload.ts`, `responseNormalizer.ts`
1
+ # Hooks
2
+
3
+ > Deep specs for the async + sync hook registries, the canonical core-owned payload shapes, `processUpload`, and the response normalizer family. Source: `packages/core/src/hooks/`, `processUpload.ts`, `responseNormalizer.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ LuckyStack ships two hook registries:
8
+
9
+ - **Async hook registry** (`registerHook` / `dispatchHook`) — used by lifecycle events that can tolerate await (`preApiExecute`, `postApiRespond`, `corsRejected`, etc.). Handlers can return a `HookStopSignal` to abort the framework's main flow.
10
+ - **Sync mutator registry** (`registerSyncHook` / `dispatchSyncHook`) — used by hot-path mutators that must remain synchronous (`preErrorNormalize`, `postErrorNormalize`). Handlers mutate the payload object in place; there is no stop signal.
11
+
12
+ Both registries swallow handler exceptions: errors are routed to `getLogger().error` + `captureException` so a misbehaving handler can never crash the framework. Multiple handlers per hook are supported and invoked in registration order.
13
+
14
+ Feature packages extend `HookPayloads` via TypeScript module augmentation. The augmentation file must be in a tsconfig `include` path AND side-effect imported from the package barrel (`import './hookPayloads';`) so TS merges the declarations.
15
+
16
+ `processUpload` is included in this topic because it's the public API that ties the `onUploadStart` / `onUploadComplete` hook contract together for consumer upload routes.
17
+
18
+ ## Lifecycle flow (api request)
19
+
20
+ ```
21
+ client emits apiRequest
22
+ ├─> preHttpRequest (async — read-only meta)
23
+ ├─> preApiValidate (async — can stop)
24
+ ├─> [framework validation]
25
+ ├─> postApiValidate (async — read-only)
26
+ ├─> preApiExecute (async — can stop)
27
+ ├─> [handler.main()]
28
+ ├─> postApiExecute (async — read-only + durationMs)
29
+ ├─> preApiRespond (async — mutate response in place)
30
+ ├─> transformApiResponse (async — mutate response in place)
31
+ └─> postApiRespond (async — read-only, after wire-send)
32
+
33
+ On error:
34
+ ├─> preErrorNormalize (sync mutator)
35
+ ├─> [responseNormalizer]
36
+ ├─> postErrorNormalize (sync mutator)
37
+ └─> apiError (async — observability)
38
+ ```
39
+
40
+ The sync flow has its own analogues (`preSyncAuthorize`, `preSyncFanout`, `postSyncFanout`, `preSyncStream`, `postSyncStream`, `syncError`).
41
+
42
+ ## Types
43
+
44
+ ```typescript
45
+ export interface HookSessionShape {
46
+ id: string;
47
+ token: string;
48
+ email?: string | null;
49
+ name?: string | null;
50
+ avatar?: string | null;
51
+ avatarFallback?: string | null;
52
+ admin?: boolean | null;
53
+ language?: string | null;
54
+ roomCodes?: string[];
55
+ }
56
+
57
+ export interface HookStopSignal {
58
+ stop: true;
59
+ errorCode: string;
60
+ httpStatus?: number;
61
+ }
62
+
63
+ export type HookResult = undefined | HookStopSignal;
64
+ export type HookHandler<TPayload> = (payload: TPayload) => Promise<HookResult> | HookResult;
65
+ export type SyncHookHandler<TPayload> = (payload: TPayload) => void;
66
+
67
+ export type DispatchResult =
68
+ | { stopped: false }
69
+ | { stopped: true; signal: HookStopSignal };
70
+
71
+ export type HookName = keyof HookPayloads;
72
+ export type SyncHookName = keyof SyncHookPayloads;
73
+ ```
74
+
75
+ `HookSessionShape` is defined in core (not imported from login) so the core hook layer stays free of feature-package deps. Any concrete `BaseSessionLayout` / project `SessionLayout` is structurally assignable.
76
+
77
+ ## API Reference — Async Hooks
78
+
79
+ ### `registerHook<TName>(name, handler): void`
80
+
81
+ **Signature:**
82
+ ```typescript
83
+ export const registerHook = <TName extends HookName>(
84
+ name: TName,
85
+ handler: HookHandler<HookPayloads[TName]>,
86
+ ): void
87
+ ```
88
+
89
+ **Behavior:** Appends the handler to the (string-keyed) list of handlers for `name`. Multiple registrations are allowed; order is preserved.
90
+
91
+ ### `dispatchHook<TName>(name, payload): Promise<DispatchResult>`
92
+
93
+ **Signature:**
94
+ ```typescript
95
+ export const dispatchHook = async <TName extends HookName>(
96
+ name: TName,
97
+ payload: HookPayloads[TName],
98
+ ): Promise<DispatchResult>
99
+ ```
100
+
101
+ **Behavior (in order):**
102
+ 1. Reads the handler list for `name` (empty array if none).
103
+ 2. For each handler:
104
+ - Awaits `handler(payload)` inside a try/catch.
105
+ - On thrown error → logs `hook: handler for "<name>" threw` via `getLogger().error` + `captureException(error, { hook: name })`. Continues with the next handler.
106
+ - On returned `HookStopSignal` (any non-undefined return value) → returns `{ stopped: true, signal: result }` immediately. Remaining handlers do NOT run.
107
+ 3. After all handlers complete without a stop → returns `{ stopped: false }`.
108
+
109
+ **Mutation contract:** Handlers receive the same payload object reference. For payloads that explicitly support mutation (`PreApiRespondPayload.response`, `transformApiResponse`), mutating in place is the documented mechanism to replace the outgoing response without a stop signal.
110
+
111
+ ### `clearAllHooks(): void`
112
+
113
+ Test-only. Clears both the async and sync hook registries. Never call from production paths — framework-internal hooks (presence cleanup, etc.) would be wiped.
114
+
115
+ ## API Reference — Sync Hooks
116
+
117
+ ### `registerSyncHook<TName>(name, handler): void`
118
+
119
+ **Behavior:** Same shape as `registerHook` but appends to the sync map.
120
+
121
+ ### `dispatchSyncHook<TName>(name, payload): void`
122
+
123
+ **Behavior:**
124
+ - Invokes every handler synchronously.
125
+ - Per-handler try/catch isolates failures (same logger + `captureException` path as async).
126
+ - Cannot stop the flow. Handlers mutate `payload` in place.
127
+
128
+ **When to use sync:** Only when the call site is on a hot path that genuinely cannot afford to `await`. The current usage is `normalizeErrorResponse` because it's invoked from many places in a single request (api error path, sync error path, transport boundaries).
129
+
130
+ ## Core-owned payload shapes
131
+
132
+ ### API lifecycle
133
+
134
+ | Hook | Payload (key fields) |
135
+ |---|---|
136
+ | `preHttpRequest` | `method`, `url`, `requestId`, `origin`, `headers` (sanitized — no auth/cookie). |
137
+ | `preApiValidate` | `routeName`, `data`, `user`. |
138
+ | `postApiValidate` | extends `preApiValidate` + `validation: { status: 'success' } \| { status: 'error', message }`. |
139
+ | `preApiExecute` | `routeName`, `data`, `user`. |
140
+ | `postApiExecute` | `routeName`, `data`, `user`, `result`, `error`, `durationMs`. |
141
+ | `preApiRespond` | `routeName`, `user`, `response: ApiResponseEnvelope` (mutable). |
142
+ | `transformApiResponse` | Same shape — separate hook so plugins can compose without ordering with `preApiRespond`. |
143
+ | `postApiRespond` | `routeName`, `user`, `response` (immutable after wire-send). |
144
+
145
+ ### Sync lifecycle
146
+
147
+ | Hook | Payload (key fields) |
148
+ |---|---|
149
+ | `preSyncAuthorize` | `routeName`, `data` (raw), `user`, `receiver`. |
150
+ | `preSyncFanout` | `routeName`, `data`, `user`, `receiver`, `serverOutput`. |
151
+ | `postSyncFanout` | extends `preSyncFanout` + `recipientCount`. |
152
+ | `preSyncStream` | `routeName`, `chunk`, `recipient`. |
153
+ | `postSyncStream` | extends `preSyncStream` + `chunkIndex`. |
154
+
155
+ ### Error / security signals
156
+
157
+ | Hook | Payload (key fields) |
158
+ |---|---|
159
+ | `apiError` | `route`, `method?`, `requestId?`, `user?`, `error`. |
160
+ | `syncError` | Same shape. |
161
+ | `rateLimitExceeded` | `scope`, `key` (sanitized), `limit`, `windowMs`, `count`, `route?`, `ip?`, `userId?`. |
162
+ | `corsRejected` | `origin`, `normalizedOrigin`, `allowedOrigins`, `allowLocalhost`, `route?`. |
163
+ | `csrfMismatch` | `route`, `method?`, `requestId?`, `userId?`, `providedToken: boolean`. |
164
+
165
+ ### Session refresh
166
+
167
+ | Hook | Payload (key fields) |
168
+ |---|---|
169
+ | `preSessionRefresh` | `token`, `userId`, `oldTtl`, `newTtl`. |
170
+ | `postSessionRefresh` | extends + `applied: boolean`. |
171
+
172
+ ### Uploads
173
+
174
+ | Hook | Payload (key fields) |
175
+ |---|---|
176
+ | `onUploadStart` | `userId`, `contentType`, `sizeBytes`, `uploadKind`. Can return `HookStopSignal` (e.g. content moderation reject). |
177
+ | `onUploadComplete` | `userId`, `fileName`, `sizeBytes`, `uploadKind`. Read-only — fires after disk write. |
178
+
179
+ ### Sync mutator hooks
180
+
181
+ | Hook | Payload (key fields) |
182
+ |---|---|
183
+ | `preErrorNormalize` | `response: ErrorResponseInput` (mutable), `preferredLocale?`, `userLanguage?`, `fallbackHttpStatus?`. |
184
+ | `postErrorNormalize` | `normalized: NormalizedErrorResponse` (mutable), `preferredLocale?`, `userLanguage?`. |
185
+
186
+ ## API Reference — processUpload
187
+
188
+ ### `processUpload(input: ProcessUploadInput): Promise<ProcessUploadResult>`
189
+
190
+ **Signature:**
191
+ ```typescript
192
+ export interface ProcessUploadInput {
193
+ userId: string;
194
+ contentType: string;
195
+ buffer: Buffer;
196
+ uploadKind?: string; // default 'avatar'
197
+ fileName: string;
198
+ encodeAndSave: (buffer: Buffer) => Promise<number>;
199
+ }
200
+
201
+ export type ProcessUploadResult =
202
+ | { status: 'success'; sizeBytes: number }
203
+ | { status: 'rejected'; errorCode: string }
204
+ | { status: 'error'; reason: string; cause?: unknown };
205
+ ```
206
+
207
+ **Behavior (in order):**
208
+ 1. Resolves `uploadKind` (defaults to `'avatar'`).
209
+ 2. Dispatches `onUploadStart` with `{ userId, contentType, sizeBytes: buffer.byteLength, uploadKind }`. If a handler returned a stop signal → returns `{ status: 'rejected', errorCode: signal.errorCode }`.
210
+ 3. Runs `input.encodeAndSave(buffer)` inside `tryCatch`. On error → returns `{ status: 'error', reason: error.message || 'encode-failed', cause: error }`.
211
+ 4. Dispatches `onUploadComplete` with `{ userId, fileName, sizeBytes: finalSize ?? buffer.byteLength, uploadKind }`.
212
+ 5. Returns `{ status: 'success', sizeBytes }`.
213
+
214
+ **Why a callback (not built-in encoder):** core stays free of heavy native deps (sharp, ffmpeg, S3 SDKs). The consumer plugs in whichever encoder it needs and core only brackets the call with the hook contract.
215
+
216
+ **Example:** see `src/settings/_api/updateUser_v1.ts` for the canonical avatar usage.
217
+
218
+ ## API Reference — Response normalizer family
219
+
220
+ ### Types
221
+ ```typescript
222
+ export interface ErrorParam {
223
+ key: string;
224
+ value: string | number | boolean;
225
+ }
226
+
227
+ export interface ErrorResponseInput {
228
+ status?: unknown;
229
+ errorCode?: unknown;
230
+ errorParams?: unknown;
231
+ httpStatus?: unknown;
232
+ }
233
+
234
+ export interface NormalizedErrorResponse {
235
+ status: 'error';
236
+ message: string;
237
+ errorCode: string;
238
+ errorParams?: ErrorParam[];
239
+ httpStatus?: number;
240
+ }
241
+
242
+ export const INVALID_ERROR_RESPONSE_CODE = 'error.invalidResponse';
243
+ ```
244
+
245
+ ### `isErrorParamArray(value: unknown): value is ErrorParam[]`
246
+
247
+ Type guard. Returns `true` when `value` is an array and every element has a string `key` plus a `string | number | boolean` `value`.
248
+
249
+ ### `normalizeErrorResponseCore({ response, fallbackHttpStatus?, fallbackErrorCode?, resolveMessage? })`
250
+
251
+ **Behavior:**
252
+ - Extracts `errorParams` (defaults to `undefined` when not an `ErrorParam[]`).
253
+ - Reads `errorCode` (trimmed string). Falls back to `fallbackErrorCode` then `INVALID_ERROR_RESPONSE_CODE`.
254
+ - Reads `httpStatus` only when `typeof === 'number'`; otherwise uses `fallbackHttpStatus`.
255
+ - Calls `resolveMessage?.({ errorCode, errorParams })` when provided; otherwise uses the raw `errorCode` as `message`.
256
+ - Returns `{ status: 'error', message, errorCode, errorParams?, httpStatus? }`.
257
+
258
+ ### `defaultHttpStatusForResponse({ status, explicitHttpStatus?, fallbackErrorStatus = 400 })`
259
+
260
+ Returns `explicitHttpStatus` when numeric, else `200` for `'success'` and `fallbackErrorStatus` for `'error'`.
261
+
262
+ The localized `normalizeErrorResponse` wrapper (in `localizedNormalizer.ts`) layers i18n + the `preErrorNormalize` / `postErrorNormalize` sync hooks on top of `normalizeErrorResponseCore` — see source for the full surface.
263
+
264
+ ## Extending HookPayloads (feature packages)
265
+
266
+ ```typescript
267
+ // packages/<feature>/src/hookPayloads.ts
268
+ import type { HookSessionShape } from '@luckystack/core';
269
+
270
+ declare module '@luckystack/core' {
271
+ interface HookPayloads {
272
+ myFeatureHook: { userId: string; data: { foo: number } };
273
+ }
274
+ }
275
+
276
+ export {};
277
+ ```
278
+
279
+ Then side-effect import from the package barrel so the augmentation is reachable:
280
+
281
+ ```typescript
282
+ // packages/<feature>/src/index.ts
283
+ import './hookPayloads';
284
+ export { /* ... */ };
285
+ ```
286
+
287
+ ## Edge cases
288
+
289
+ - A handler that returns `undefined` continues the chain; ANY non-`undefined` return value is treated as a stop signal (the type is `undefined | HookStopSignal`, so returning a plain string would be a type error in TS but a stop in JS — keep the typed signature).
290
+ - `clearAllHooks` clears BOTH registries; if you only need to drop async handlers, register fresh sync ones afterwards.
291
+ - `dispatchSyncHook` ignores handler return values entirely — mutate `payload` in place.
292
+
293
+ ## Related
294
+
295
+ - Function INDEX: `packages/core/CLAUDE.md`
296
+ - Architecture: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`
297
+ - README: `packages/core/README.md`
298
+ - Source: `packages/core/src/hooks/registry.ts`, `hooks/types.ts`, `processUpload.ts`, `responseNormalizer.ts`