@luckystack/api 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,14 +1,14 @@
1
- # Changelog
2
-
3
- All notable changes to `@luckystack/api` are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [0.1.0]
11
-
12
- ### Added
13
-
14
- - Initial public release as part of the LuckyStack package split.
1
+ # Changelog
2
+
3
+ All notable changes to `@luckystack/api` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ ### Added
13
+
14
+ - Initial public release as part of the LuckyStack package split.
@@ -1,280 +1,280 @@
1
- # API Request Lifecycle
2
-
3
- > Last updated: 2026-05-20
4
-
5
- ## Overview
6
-
7
- `@luckystack/api` exposes two transport adapters — `handleApiRequest` (socket.io) and `handleHttpApiRequest` (raw HTTP) — that share an identical 13-step pipeline. The pipeline takes a transport payload, resolves it to a registered route in the runtime maps emitted by `@luckystack/devkit`, runs authentication / rate-limit / input-validation gates, dispatches a fixed set of lifecycle hooks, executes the route's `main(...)` function under a `tryCatch` boundary, then normalizes the result into a localized envelope before emitting it on the wire.
8
-
9
- Both transports are pure functions over the runtime maps + session state. Neither one mutates module state or constructs the io server — the socket and HTTP listeners in `@luckystack/server` import these handlers and feed them messages. Consumers building a third transport (queue worker, gRPC bridge, Lambda adapter) should call the same handlers with a synthetic `Socket` or with the HTTP signature so they get the identical contract.
10
-
11
- The full ordering is authoritative. Deviating in a fork breaks every downstream consumer that relies on the hook payloads firing in a known order (audit handlers, rate-limit dashboards, response-mutating middleware).
12
-
13
- ## Pipeline order (13 steps)
14
-
15
- Both transports execute the same sequence. The HTTP transport adds one extra step (method check).
16
-
17
- 1. **Parse message / payload shape.** Socket transport calls `validateApiMessage` to confirm `responseIndex` is present and `name` / `data` are well-typed. HTTP transport inline-checks `name` (string) and `data` (object); rejects with `api.invalidName` / `api.invalidDataObject` if either is malformed.
18
- 2. **`getSession(token)` + `setSentryUser(...)`.** Reads the session from `@luckystack/login`, identifies the user to every registered error tracker via `setSentryUser`. Preferred locale is also extracted here from `x-language` / `accept-language` headers using `extractLanguageFromHeader`.
19
- 3. **`parseTransportRouteName({ value, prefix: 'api' })`.** Rejects names that are not of the form `api/<page>/<name>/v<N>`. Returns the normalized name used downstream as `resolvedName`. Bad shapes return `routing.invalidServiceRouteName` with the original `name` echoed back as `errorParams[0].value`.
20
- 4. **Built-in `system/logout` shortcut (socket only).** When the parsed route normalizes to `system/logout`, the handler calls `logout({ token, socket, userId })` from `@luckystack/login` and short-circuits with a `{ status: 'success', httpStatus: 200, result: true }` envelope. HTTP transport does not have this shortcut — logout over HTTP goes through the normal route execution.
21
- 5. **`getRuntimeApiMaps()` + route lookup.** Reads the registered `apisObject` / `functionsObject`. Missing routes return `api.notFound` with `404`.
22
- 6. **`checkApiAuth({ apiEntry, user, name })`.** Two-stage auth gate: first checks `auth.login` (returns `auth.required` + `401` if missing), then runs `validateRequest({ auth, user })` which evaluates `auth.additional[]` predicates. Failures return `auth.forbidden` (or the predicate's overridden `errorCode`) with the predicate's `httpStatus` or `403` fallback. See [`./auth-flow.md`](./auth-flow.md).
23
- 7. **`applyApiRateLimits(...)`.** Two-bucket rate limit: per-route (uses the route's `rateLimit` export, or `rateLimiting.defaultApiLimit` fallback) keyed by `<token|ip>:<id>:api:<route>`, then a global per-IP bucket keyed by `ip:<ip>:api:all` (uses `rateLimiting.defaultIpLimit`). Either rejection emits `rateLimitExceeded` and returns `api.rateLimitExceeded` + `429`. See [`./rate-limiting.md`](./rate-limiting.md).
24
- 8. **HTTP-only: method check.** `expectedMethod = apiEntry.httpMethod ?? inferHttpMethod(resolvedName)`. Mismatched methods return `api.methodNotAllowed` + `405` with `errorParams: [{ key: 'method', value: expectedMethod }]`. Method check runs after auth + rate limit, on purpose — unauthenticated callers should not learn whether a method-incorrect route exists.
25
- 9. **`preApiValidate` hook → `validateInputByType(...)` → `postApiValidate` hook.** Hook fires before validation runs (handlers can mutate `data`, can NOT stop). `validateInputByType` checks the payload against the Zod-equivalent schema derived from the route's `inputType` text. On the socket transport only, the per-route `validation: 'relaxed'` or `validation: { input: 'skip' }` flag skips validation entirely (useful for third-party webhooks). Validation failures return `api.invalidInputType` with the validator's message echoed in `errorParams[0].value`. `postApiValidate` always fires with the resulting validation result, even on skip.
26
- 10. **`preApiExecute` hook.** Last gate before execution. A handler may return a `HookStopSignal` (`{ stop: true, errorCode, httpStatus? }`) to abort. Stop signals are normalized into a localized error envelope using the signal's `errorCode` (or `403` if no `httpStatus`).
27
- 11. **`executeApiHandler(...)`.** Wraps `apiEntry.main({ data, user, functions, stream })` in `tryCatch` (which captures any thrown error to Sentry via the active `@luckystack/error-tracking` adapter) and in a `startSpan(name, 'api.request')` (HTTP variant uses `'api.request.http'`). Records `durationMs`.
28
- 12. **`postApiExecute` hook.** Observation point. Receives `result`, `error`, and `durationMs`. Use for audit logging, metrics, or post-run side effects. Cannot mutate the response.
29
- 13. **`buildApiResponseEnvelope` → `preApiRespond` → `transformApiResponse` → emit → `postApiRespond`.** The envelope normalizer maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse`. `preApiRespond` lets handlers mutate `payload.response` in place or return a stop signal that converts to a localized error envelope. `transformApiResponse` is the dedicated mutation hook (PII redaction, response signing, schema injection) that fires after `preApiRespond` but before emit. Emit happens via `socket.emit(buildApiResponseEventName(responseIndex), ...)` (socket) or as the HTTP response body (HTTP). `postApiRespond` is observation-only and fires after emit.
30
-
31
- ## API Reference
32
-
33
- ### `handleApiRequest({ msg, socket, token })`
34
-
35
- Socket.io transport adapter. Default export.
36
-
37
- **Signature**
38
-
39
- ```ts
40
- import handleApiRequest from '@luckystack/api';
41
- import type { apiMessage } from '@luckystack/core';
42
- import type { Socket } from 'socket.io';
43
-
44
- export default async function handleApiRequest(args: {
45
- msg: apiMessage;
46
- socket: Socket;
47
- token: string | null;
48
- }): Promise<void>;
49
- ```
50
-
51
- **Parameters**
52
-
53
- | Field | Type | Notes |
54
- | --- | --- | --- |
55
- | `msg` | `apiMessage` | The raw transport envelope. Required fields: `name` (string, `api/<page>/<name>/v<N>`), `data` (object), `responseIndex` (number, used to build the response event name). |
56
- | `socket` | `Socket` | Live socket.io socket. Used for `socket.emit`, `socket.handshake.headers`, and `socket.handshake.address` (for the global IP rate-limit bucket). |
57
- | `token` | `string \| null` | Session token from the socket handshake (`extractTokenFromSocket`). Drives `getSession`, the per-route rate-limit bucket key, and the `system/logout` shortcut. |
58
-
59
- **Returns**
60
-
61
- `Promise<void>`. The response is delivered by `socket.emit(buildApiResponseEventName(responseIndex), envelope)`. The function resolves once `postApiRespond` has completed.
62
-
63
- **Behavior highlights**
64
-
65
- - Treats `responseIndex` as the response correlation token; clients listen on `buildApiResponseEventName(responseIndex)`.
66
- - Reads `x-language` / `accept-language` from `socket.handshake.headers` for locale extraction.
67
- - Honors a per-route `validation: 'relaxed'` / `{ input: 'skip' }` flag (HTTP transport does not — webhook-style endpoints typically live on HTTP and pre-date this flag).
68
- - Built-in route: `system/logout` exits with a success envelope after calling `logout(...)` from `@luckystack/login`.
69
- - Stream emission: routes that call `stream(payload)` from their `main(...)` cause `socket.emit(buildApiStreamEventName(responseIndex), payload)`. Stream payloads are typed as `Record<string, unknown>` at the transport layer; consumers narrow against the generated `ApiStream<P, N, V>` type.
70
-
71
- **Errors / framework-emitted error codes**
72
-
73
- | `errorCode` | When | HTTP status |
74
- | --- | --- | --- |
75
- | `api.invalidRequest` | `name` or `data` missing / wrong type. | 400 |
76
- | `routing.invalidServiceRouteName` | `name` is not `api/<page>/<name>/v<N>`. | 400 |
77
- | `api.notFound` | Route not registered in runtime maps. | 404 |
78
- | `auth.required` | `auth.login` is `true` and `user?.id` is missing. | 401 |
79
- | `auth.forbidden` | `auth.additional[]` predicate rejects. | 403 |
80
- | `auth.invalidCondition` | `auth.additional[]` references a session key that is not on the session shape. Setup error. | 500 |
81
- | `api.rateLimitExceeded` | Per-route or per-IP bucket rejects. `errorParams[0].value` is `resetIn` (seconds). | 429 |
82
- | `api.invalidInputType` | `validateInputByType` rejects the payload. `errorParams[0].value` is the validator's message. | 400 |
83
- | `api.internalServerError` | `main(...)` threw. | 500 |
84
- | `api.emptyResponse` | `main(...)` returned `null` or `undefined`. | 500 |
85
- | `api.invalidResponseStatus` | Handler returned an object whose `status` is neither `'success'` nor `'error'`. | 500 |
86
-
87
- **Example**
88
-
89
- ```ts
90
- import handleApiRequest from '@luckystack/api';
91
- import { extractTokenFromSocket } from '@luckystack/core';
92
-
93
- io.on('connection', (socket) => {
94
- socket.on('apiRequest', (msg) => {
95
- void handleApiRequest({
96
- msg,
97
- socket,
98
- token: extractTokenFromSocket(socket),
99
- });
100
- });
101
- });
102
- ```
103
-
104
- ### `handleHttpApiRequest({ name, data, token, ... })`
105
-
106
- HTTP transport adapter. Same pipeline as the socket variant plus HTTP-method validation.
107
-
108
- **Signature**
109
-
110
- ```ts
111
- import { handleHttpApiRequest } from '@luckystack/api';
112
- import type { HttpMethod } from '@luckystack/core';
113
-
114
- export type ApiHttpStreamEvent = Record<string, unknown>;
115
-
116
- export async function handleHttpApiRequest(params: {
117
- name: string;
118
- data: Record<string, unknown>;
119
- token: string | null;
120
- requesterIp?: string;
121
- xLanguageHeader?: string | string[];
122
- acceptLanguageHeader?: string | string[];
123
- method?: HttpMethod;
124
- stream?: (payload: ApiHttpStreamEvent) => void;
125
- }): Promise<ApiNetworkResponse>;
126
- ```
127
-
128
- **Parameters**
129
-
130
- | Field | Type | Notes |
131
- | --- | --- | --- |
132
- | `name` | `string` | Route name. May be passed with or without the `api/` prefix; the handler normalizes both. |
133
- | `data` | `Record<string, unknown>` | Request payload. Must be an object (arrays, primitives rejected). |
134
- | `token` | `string \| null` | Session token from cookie or `Authorization: Bearer`. |
135
- | `requesterIp` | `string` (optional) | IP used by the global per-IP rate-limit bucket. Falls back to `'unknown'` when omitted; production callers should always pass it. |
136
- | `xLanguageHeader` | `string \| string[]` (optional) | Forwarded to `extractLanguageFromHeader`. Takes precedence over `acceptLanguageHeader`. |
137
- | `acceptLanguageHeader` | `string \| string[]` (optional) | Standard `Accept-Language` header. |
138
- | `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` (default `'POST'`) | HTTP method on the wire. Compared against the route's declared `httpMethod` or the inferred method from `inferHttpMethod`. |
139
- | `stream` | `(payload) => void` (optional) | Callback invoked once per `stream(payload)` call from the route's `main`. The server's HTTP listener wraps this into SSE events (`event: stream`). Streaming endpoints emit a final `event: final` carrying the full envelope. |
140
-
141
- **Returns**
142
-
143
- `Promise<ApiNetworkResponse>` — exactly one of:
144
-
145
- ```ts
146
- type ApiNetworkResponse =
147
- | ({ status: 'success'; httpStatus: number } & Record<string, unknown>)
148
- | {
149
- status: 'error';
150
- httpStatus: number;
151
- message: string;
152
- errorCode: string;
153
- errorParams?: { key: string; value: string | number | boolean }[];
154
- };
155
- ```
156
-
157
- The `httpStatus` is guaranteed to be a number (the socket variant returns the envelope with `httpStatus` as a hint; the HTTP variant materializes it for the response writer to use).
158
-
159
- **Behavior highlights**
160
-
161
- - Same auth → rate-limit → validate → execute → respond order as the socket variant, with method check inserted between rate-limit and validate.
162
- - Method inference (`inferHttpMethod`): names starting with `get*` / `fetch*` / `list*` → `GET`, `delete*` / `remove*` → `DELETE`, `update*` / `edit*` / `patch*` → `PUT`, everything else → `POST`. Per-route `httpMethod` export overrides the inference.
163
- - Does NOT honor the per-route `validation: 'relaxed'` flag. Public webhooks that need looser validation must use the socket variant or model their inputs structurally.
164
- - Streaming over SSE: the `stream` callback is invoked synchronously each time the route's `main` calls `stream(payload)`. The server-side SSE writer (in `@luckystack/server`) emits each callback as `event: stream\ndata: <json>` and the final envelope as `event: final\ndata: <json>`.
165
- - The function dispatches `preApiRespond` and `postApiRespond` in `handleHttpApiRequest` itself (wrapping the inner runner), so the same response-mutation contract applies to both transports.
166
-
167
- **Errors / framework-emitted error codes**
168
-
169
- Same set as the socket variant, plus:
170
-
171
- | `errorCode` | When | HTTP status |
172
- | --- | --- | --- |
173
- | `api.invalidName` | `name` missing or not a string. | 400 |
174
- | `api.invalidDataObject` | `data` missing, null, or not an object. | 400 |
175
- | `api.methodNotAllowed` | Request method does not match the route's declared or inferred HTTP method. `errorParams[0].value` is the expected method. | 405 |
176
-
177
- **Example**
178
-
179
- ```ts
180
- import { handleHttpApiRequest } from '@luckystack/api';
181
-
182
- httpServer.on('request', async (req, res) => {
183
- if (!req.url?.startsWith('/api/')) return;
184
-
185
- const token = extractTokenFromRequest(req);
186
- const body = await readJson(req);
187
- const name = req.url.slice('/api/'.length).replace(/\?.*$/, '');
188
-
189
- const result = await handleHttpApiRequest({
190
- name,
191
- data: body,
192
- token,
193
- requesterIp: req.socket.remoteAddress ?? undefined,
194
- xLanguageHeader: req.headers['x-language'],
195
- acceptLanguageHeader: req.headers['accept-language'],
196
- method: req.method as HttpMethod,
197
- });
198
-
199
- res.statusCode = result.httpStatus;
200
- res.setHeader('content-type', 'application/json');
201
- res.end(JSON.stringify(result));
202
- });
203
- ```
204
-
205
- ### Type: `ApiHttpStreamEvent`
206
-
207
- ```ts
208
- export type ApiHttpStreamEvent = Record<string, unknown>;
209
- ```
210
-
211
- Wire-level shape of payloads emitted from `stream(...)` over the HTTP transport. Routes narrow this against the generated `ApiStream<P, N, V>` type at the call site. Bridge code (SSE writers, custom queue adapters) should treat it as opaque key/value.
212
-
213
- ## Internal pipeline helpers
214
-
215
- Not exported. Listed for traceability against the source.
216
-
217
- | Helper | File | Role |
218
- | --- | --- | --- |
219
- | `validateApiMessage` | `handleApiRequest.ts` | Rejects malformed socket envelopes. |
220
- | `checkApiAuth` | `handleApiRequest.ts` | Wraps `auth.login` gate + `validateRequest`. |
221
- | `applyApiRateLimits` | `handleApiRequest.ts` | Per-route + global per-IP bucket. Fires `rateLimitExceeded` on reject. |
222
- | `executeApiHandler` | `handleApiRequest.ts` | `tryCatch` + `startSpan` wrap around `apiEntry.main(...)`. |
223
- | `buildApiResponseEnvelope` | `handleApiRequest.ts` | Normalizes success / error result to wire envelope with inferred `httpStatus`. |
224
- | `emitApiResult` | `handleApiRequest.ts` | Runs `preApiRespond` / `transformApiResponse` / `postApiRespond` hooks and emits on the socket. |
225
- | `warnIfInputTypeMissing` | both | Dev-only one-shot warning when a route lacks a generated `inputType`. |
226
- | `runHandleHttpApiRequestInner` | `handleHttpApiRequest.ts` | The core inner pipeline for the HTTP transport. |
227
- | `isRuntimeApiResult` | `handleHttpApiRequest.ts` | Type guard rejecting handler returns whose `status` is not `'success'` / `'error'`. |
228
-
229
- ## Hooks dispatched
230
-
231
- In pipeline order:
232
-
233
- | Hook | Stoppable | Payload (key fields) |
234
- | --- | --- | --- |
235
- | `preApiValidate` | yes | `routeName`, `data`, `user` |
236
- | `postApiValidate` | no | `routeName`, `data`, `user`, `validation: { status, message? }` |
237
- | `preApiExecute` | yes | `routeName`, `data`, `user` |
238
- | `postApiExecute` | no | `routeName`, `data`, `user`, `result`, `error`, `durationMs` |
239
- | `preApiRespond` | no (mutates `response`) | `routeName`, `user`, `response: ApiResponseEnvelope` |
240
- | `transformApiResponse` | no (mutates `response`) | `routeName`, `user`, `response` |
241
- | `postApiRespond` | no | `routeName`, `user`, `response` |
242
- | `rateLimitExceeded` | no | `scope: 'route' \| 'user' \| 'ip'`, `key`, `limit`, `windowMs`, `count`, `route?`, `userId?`, `ip?` |
243
-
244
- Handlers register via `registerHook(name, handler)` from `@luckystack/core`. Stop signals are `{ stop: true, errorCode, httpStatus? }`. For deeper hook contract details see `@luckystack/core/docs/hooks.md`.
245
-
246
- ## Config keys
247
-
248
- Read at request time via `getProjectConfig()`. Never captured at module load.
249
-
250
- | Key | Type | Effect |
251
- | --- | --- | --- |
252
- | `logging.devLogs` | `boolean` | Gates verbose per-request `getLogger().debug/warn/error` output. |
253
- | `logging.stream` | `boolean` | Gates `emitStream` payload logging. |
254
- | `dev.warnOnMissingInputType` | `boolean` | Toggles the one-shot `warnIfInputTypeMissing` developer hint. |
255
- | `rateLimiting.defaultApiLimit` | `number \| false` | Per-route fallback when the route does not export `rateLimit`. `false` disables the per-route bucket. |
256
- | `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP bucket across all API routes. `false` disables. |
257
- | `rateLimiting.windowMs` | `number` | Window size for both buckets. |
258
-
259
- No environment variables are read directly by this package. Secrets and toggles travel through `@luckystack/core`'s config registry.
260
-
261
- ## Edge cases
262
-
263
- - **Missing `responseIndex` (socket).** Returns silently after a dev-log warning. The client will never get a response — by design, since we have no channel to send it on.
264
- - **Malformed `msg` (socket).** Returns silently if `msg` is not an object. Logs to dev-log.
265
- - **Unknown route after `parseTransportRouteName` succeeds.** Returns `api.notFound` with the original (pre-normalization) name in `errorParams[0].value` so audit handlers can correlate.
266
- - **Empty handler return.** Treated as an error: `api.emptyResponse` + `500`. Handlers must return an `{ status: 'success' }` or `{ status: 'error' }` object.
267
- - **Invalid `status` from handler.** Treated as an error: `api.invalidResponseStatus` + `500`.
268
- - **`preApiRespond` stop signal.** The original envelope is discarded; a fresh error envelope is built from the signal's `errorCode` + `httpStatus`. The replacement envelope still flows through `transformApiResponse` and `postApiRespond`.
269
- - **Streaming + error.** If `main(...)` throws after one or more `stream(...)` calls, the stream events were already on the wire — the final envelope is the error response. SSE clients must handle the mixed sequence.
270
-
271
- ## Related
272
-
273
- - README: [`../README.md`](../README.md)
274
- - Routing conventions: [`./routing-conventions.md`](./routing-conventions.md)
275
- - Auth flow: [`./auth-flow.md`](./auth-flow.md)
276
- - Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
277
- - Error handling: [`./error-handling.md`](./error-handling.md)
278
- - Generated types: [`./generated-types.md`](./generated-types.md)
279
- - Architecture deep-dive: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md)
280
- - Hook contract: `@luckystack/core/docs/hooks.md`
1
+ # API Request Lifecycle
2
+
3
+ > Last updated: 2026-05-20
4
+
5
+ ## Overview
6
+
7
+ `@luckystack/api` exposes two transport adapters — `handleApiRequest` (socket.io) and `handleHttpApiRequest` (raw HTTP) — that share an identical 13-step pipeline. The pipeline takes a transport payload, resolves it to a registered route in the runtime maps emitted by `@luckystack/devkit`, runs authentication / rate-limit / input-validation gates, dispatches a fixed set of lifecycle hooks, executes the route's `main(...)` function under a `tryCatch` boundary, then normalizes the result into a localized envelope before emitting it on the wire.
8
+
9
+ Both transports are pure functions over the runtime maps + session state. Neither one mutates module state or constructs the io server — the socket and HTTP listeners in `@luckystack/server` import these handlers and feed them messages. Consumers building a third transport (queue worker, gRPC bridge, Lambda adapter) should call the same handlers with a synthetic `Socket` or with the HTTP signature so they get the identical contract.
10
+
11
+ The full ordering is authoritative. Deviating in a fork breaks every downstream consumer that relies on the hook payloads firing in a known order (audit handlers, rate-limit dashboards, response-mutating middleware).
12
+
13
+ ## Pipeline order (13 steps)
14
+
15
+ Both transports execute the same sequence. The HTTP transport adds one extra step (method check).
16
+
17
+ 1. **Parse message / payload shape.** Socket transport calls `validateApiMessage` to confirm `responseIndex` is present and `name` / `data` are well-typed. HTTP transport inline-checks `name` (string) and `data` (object); rejects with `api.invalidName` / `api.invalidDataObject` if either is malformed.
18
+ 2. **`getSession(token)` + `setSentryUser(...)`.** Reads the session from `@luckystack/login`, identifies the user to every registered error tracker via `setSentryUser`. Preferred locale is also extracted here from `x-language` / `accept-language` headers using `extractLanguageFromHeader`.
19
+ 3. **`parseTransportRouteName({ value, prefix: 'api' })`.** Rejects names that are not of the form `api/<page>/<name>/v<N>`. Returns the normalized name used downstream as `resolvedName`. Bad shapes return `routing.invalidServiceRouteName` with the original `name` echoed back as `errorParams[0].value`.
20
+ 4. **Built-in `system/logout` shortcut (socket only).** When the parsed route normalizes to `system/logout`, the handler calls `logout({ token, socket, userId })` from `@luckystack/login` and short-circuits with a `{ status: 'success', httpStatus: 200, result: true }` envelope. HTTP transport does not have this shortcut — logout over HTTP goes through the normal route execution.
21
+ 5. **`getRuntimeApiMaps()` + route lookup.** Reads the registered `apisObject` / `functionsObject`. Missing routes return `api.notFound` with `404`.
22
+ 6. **`checkApiAuth({ apiEntry, user, name })`.** Two-stage auth gate: first checks `auth.login` (returns `auth.required` + `401` if missing), then runs `validateRequest({ auth, user })` which evaluates `auth.additional[]` predicates. Failures return `auth.forbidden` (or the predicate's overridden `errorCode`) with the predicate's `httpStatus` or `403` fallback. See [`./auth-flow.md`](./auth-flow.md).
23
+ 7. **`applyApiRateLimits(...)`.** Two-bucket rate limit: per-route (uses the route's `rateLimit` export, or `rateLimiting.defaultApiLimit` fallback) keyed by `<token|ip>:<id>:api:<route>`, then a global per-IP bucket keyed by `ip:<ip>:api:all` (uses `rateLimiting.defaultIpLimit`). Either rejection emits `rateLimitExceeded` and returns `api.rateLimitExceeded` + `429`. See [`./rate-limiting.md`](./rate-limiting.md).
24
+ 8. **HTTP-only: method check.** `expectedMethod = apiEntry.httpMethod ?? inferHttpMethod(resolvedName)`. Mismatched methods return `api.methodNotAllowed` + `405` with `errorParams: [{ key: 'method', value: expectedMethod }]`. Method check runs after auth + rate limit, on purpose — unauthenticated callers should not learn whether a method-incorrect route exists.
25
+ 9. **`preApiValidate` hook → `validateInputByType(...)` → `postApiValidate` hook.** Hook fires before validation runs (handlers can mutate `data`, can NOT stop). `validateInputByType` checks the payload against the Zod-equivalent schema derived from the route's `inputType` text. On the socket transport only, the per-route `validation: 'relaxed'` or `validation: { input: 'skip' }` flag skips validation entirely (useful for third-party webhooks). Validation failures return `api.invalidInputType` with the validator's message echoed in `errorParams[0].value`. `postApiValidate` always fires with the resulting validation result, even on skip.
26
+ 10. **`preApiExecute` hook.** Last gate before execution. A handler may return a `HookStopSignal` (`{ stop: true, errorCode, httpStatus? }`) to abort. Stop signals are normalized into a localized error envelope using the signal's `errorCode` (or `403` if no `httpStatus`).
27
+ 11. **`executeApiHandler(...)`.** Wraps `apiEntry.main({ data, user, functions, stream })` in `tryCatch` (which captures any thrown error to Sentry via the active `@luckystack/error-tracking` adapter) and in a `startSpan(name, 'api.request')` (HTTP variant uses `'api.request.http'`). Records `durationMs`.
28
+ 12. **`postApiExecute` hook.** Observation point. Receives `result`, `error`, and `durationMs`. Use for audit logging, metrics, or post-run side effects. Cannot mutate the response.
29
+ 13. **`buildApiResponseEnvelope` → `preApiRespond` → `transformApiResponse` → emit → `postApiRespond`.** The envelope normalizer maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse`. `preApiRespond` lets handlers mutate `payload.response` in place or return a stop signal that converts to a localized error envelope. `transformApiResponse` is the dedicated mutation hook (PII redaction, response signing, schema injection) that fires after `preApiRespond` but before emit. Emit happens via `socket.emit(buildApiResponseEventName(responseIndex), ...)` (socket) or as the HTTP response body (HTTP). `postApiRespond` is observation-only and fires after emit.
30
+
31
+ ## API Reference
32
+
33
+ ### `handleApiRequest({ msg, socket, token })`
34
+
35
+ Socket.io transport adapter. Default export.
36
+
37
+ **Signature**
38
+
39
+ ```ts
40
+ import handleApiRequest from '@luckystack/api';
41
+ import type { apiMessage } from '@luckystack/core';
42
+ import type { Socket } from 'socket.io';
43
+
44
+ export default async function handleApiRequest(args: {
45
+ msg: apiMessage;
46
+ socket: Socket;
47
+ token: string | null;
48
+ }): Promise<void>;
49
+ ```
50
+
51
+ **Parameters**
52
+
53
+ | Field | Type | Notes |
54
+ | --- | --- | --- |
55
+ | `msg` | `apiMessage` | The raw transport envelope. Required fields: `name` (string, `api/<page>/<name>/v<N>`), `data` (object), `responseIndex` (number, used to build the response event name). |
56
+ | `socket` | `Socket` | Live socket.io socket. Used for `socket.emit`, `socket.handshake.headers`, and `socket.handshake.address` (for the global IP rate-limit bucket). |
57
+ | `token` | `string \| null` | Session token from the socket handshake (`extractTokenFromSocket`). Drives `getSession`, the per-route rate-limit bucket key, and the `system/logout` shortcut. |
58
+
59
+ **Returns**
60
+
61
+ `Promise<void>`. The response is delivered by `socket.emit(buildApiResponseEventName(responseIndex), envelope)`. The function resolves once `postApiRespond` has completed.
62
+
63
+ **Behavior highlights**
64
+
65
+ - Treats `responseIndex` as the response correlation token; clients listen on `buildApiResponseEventName(responseIndex)`.
66
+ - Reads `x-language` / `accept-language` from `socket.handshake.headers` for locale extraction.
67
+ - Honors a per-route `validation: 'relaxed'` / `{ input: 'skip' }` flag (HTTP transport does not — webhook-style endpoints typically live on HTTP and pre-date this flag).
68
+ - Built-in route: `system/logout` exits with a success envelope after calling `logout(...)` from `@luckystack/login`.
69
+ - Stream emission: routes that call `stream(payload)` from their `main(...)` cause `socket.emit(buildApiStreamEventName(responseIndex), payload)`. Stream payloads are typed as `Record<string, unknown>` at the transport layer; consumers narrow against the generated `ApiStream<P, N, V>` type.
70
+
71
+ **Errors / framework-emitted error codes**
72
+
73
+ | `errorCode` | When | HTTP status |
74
+ | --- | --- | --- |
75
+ | `api.invalidRequest` | `name` or `data` missing / wrong type. | 400 |
76
+ | `routing.invalidServiceRouteName` | `name` is not `api/<page>/<name>/v<N>`. | 400 |
77
+ | `api.notFound` | Route not registered in runtime maps. | 404 |
78
+ | `auth.required` | `auth.login` is `true` and `user?.id` is missing. | 401 |
79
+ | `auth.forbidden` | `auth.additional[]` predicate rejects. | 403 |
80
+ | `auth.invalidCondition` | `auth.additional[]` references a session key that is not on the session shape. Setup error. | 500 |
81
+ | `api.rateLimitExceeded` | Per-route or per-IP bucket rejects. `errorParams[0].value` is `resetIn` (seconds). | 429 |
82
+ | `api.invalidInputType` | `validateInputByType` rejects the payload. `errorParams[0].value` is the validator's message. | 400 |
83
+ | `api.internalServerError` | `main(...)` threw. | 500 |
84
+ | `api.emptyResponse` | `main(...)` returned `null` or `undefined`. | 500 |
85
+ | `api.invalidResponseStatus` | Handler returned an object whose `status` is neither `'success'` nor `'error'`. | 500 |
86
+
87
+ **Example**
88
+
89
+ ```ts
90
+ import handleApiRequest from '@luckystack/api';
91
+ import { extractTokenFromSocket } from '@luckystack/core';
92
+
93
+ io.on('connection', (socket) => {
94
+ socket.on('apiRequest', (msg) => {
95
+ void handleApiRequest({
96
+ msg,
97
+ socket,
98
+ token: extractTokenFromSocket(socket),
99
+ });
100
+ });
101
+ });
102
+ ```
103
+
104
+ ### `handleHttpApiRequest({ name, data, token, ... })`
105
+
106
+ HTTP transport adapter. Same pipeline as the socket variant plus HTTP-method validation.
107
+
108
+ **Signature**
109
+
110
+ ```ts
111
+ import { handleHttpApiRequest } from '@luckystack/api';
112
+ import type { HttpMethod } from '@luckystack/core';
113
+
114
+ export type ApiHttpStreamEvent = Record<string, unknown>;
115
+
116
+ export async function handleHttpApiRequest(params: {
117
+ name: string;
118
+ data: Record<string, unknown>;
119
+ token: string | null;
120
+ requesterIp?: string;
121
+ xLanguageHeader?: string | string[];
122
+ acceptLanguageHeader?: string | string[];
123
+ method?: HttpMethod;
124
+ stream?: (payload: ApiHttpStreamEvent) => void;
125
+ }): Promise<ApiNetworkResponse>;
126
+ ```
127
+
128
+ **Parameters**
129
+
130
+ | Field | Type | Notes |
131
+ | --- | --- | --- |
132
+ | `name` | `string` | Route name. May be passed with or without the `api/` prefix; the handler normalizes both. |
133
+ | `data` | `Record<string, unknown>` | Request payload. Must be an object (arrays, primitives rejected). |
134
+ | `token` | `string \| null` | Session token from cookie or `Authorization: Bearer`. |
135
+ | `requesterIp` | `string` (optional) | IP used by the global per-IP rate-limit bucket. Falls back to `'unknown'` when omitted; production callers should always pass it. |
136
+ | `xLanguageHeader` | `string \| string[]` (optional) | Forwarded to `extractLanguageFromHeader`. Takes precedence over `acceptLanguageHeader`. |
137
+ | `acceptLanguageHeader` | `string \| string[]` (optional) | Standard `Accept-Language` header. |
138
+ | `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` (default `'POST'`) | HTTP method on the wire. Compared against the route's declared `httpMethod` or the inferred method from `inferHttpMethod`. |
139
+ | `stream` | `(payload) => void` (optional) | Callback invoked once per `stream(payload)` call from the route's `main`. The server's HTTP listener wraps this into SSE events (`event: stream`). Streaming endpoints emit a final `event: final` carrying the full envelope. |
140
+
141
+ **Returns**
142
+
143
+ `Promise<ApiNetworkResponse>` — exactly one of:
144
+
145
+ ```ts
146
+ type ApiNetworkResponse =
147
+ | ({ status: 'success'; httpStatus: number } & Record<string, unknown>)
148
+ | {
149
+ status: 'error';
150
+ httpStatus: number;
151
+ message: string;
152
+ errorCode: string;
153
+ errorParams?: { key: string; value: string | number | boolean }[];
154
+ };
155
+ ```
156
+
157
+ The `httpStatus` is guaranteed to be a number (the socket variant returns the envelope with `httpStatus` as a hint; the HTTP variant materializes it for the response writer to use).
158
+
159
+ **Behavior highlights**
160
+
161
+ - Same auth → rate-limit → validate → execute → respond order as the socket variant, with method check inserted between rate-limit and validate.
162
+ - Method inference (`inferHttpMethod`): names starting with `get*` / `fetch*` / `list*` → `GET`, `delete*` / `remove*` → `DELETE`, `update*` / `edit*` / `patch*` → `PUT`, everything else → `POST`. Per-route `httpMethod` export overrides the inference.
163
+ - Does NOT honor the per-route `validation: 'relaxed'` flag. Public webhooks that need looser validation must use the socket variant or model their inputs structurally.
164
+ - Streaming over SSE: the `stream` callback is invoked synchronously each time the route's `main` calls `stream(payload)`. The server-side SSE writer (in `@luckystack/server`) emits each callback as `event: stream\ndata: <json>` and the final envelope as `event: final\ndata: <json>`.
165
+ - The function dispatches `preApiRespond` and `postApiRespond` in `handleHttpApiRequest` itself (wrapping the inner runner), so the same response-mutation contract applies to both transports.
166
+
167
+ **Errors / framework-emitted error codes**
168
+
169
+ Same set as the socket variant, plus:
170
+
171
+ | `errorCode` | When | HTTP status |
172
+ | --- | --- | --- |
173
+ | `api.invalidName` | `name` missing or not a string. | 400 |
174
+ | `api.invalidDataObject` | `data` missing, null, or not an object. | 400 |
175
+ | `api.methodNotAllowed` | Request method does not match the route's declared or inferred HTTP method. `errorParams[0].value` is the expected method. | 405 |
176
+
177
+ **Example**
178
+
179
+ ```ts
180
+ import { handleHttpApiRequest } from '@luckystack/api';
181
+
182
+ httpServer.on('request', async (req, res) => {
183
+ if (!req.url?.startsWith('/api/')) return;
184
+
185
+ const token = extractTokenFromRequest(req);
186
+ const body = await readJson(req);
187
+ const name = req.url.slice('/api/'.length).replace(/\?.*$/, '');
188
+
189
+ const result = await handleHttpApiRequest({
190
+ name,
191
+ data: body,
192
+ token,
193
+ requesterIp: req.socket.remoteAddress ?? undefined,
194
+ xLanguageHeader: req.headers['x-language'],
195
+ acceptLanguageHeader: req.headers['accept-language'],
196
+ method: req.method as HttpMethod,
197
+ });
198
+
199
+ res.statusCode = result.httpStatus;
200
+ res.setHeader('content-type', 'application/json');
201
+ res.end(JSON.stringify(result));
202
+ });
203
+ ```
204
+
205
+ ### Type: `ApiHttpStreamEvent`
206
+
207
+ ```ts
208
+ export type ApiHttpStreamEvent = Record<string, unknown>;
209
+ ```
210
+
211
+ Wire-level shape of payloads emitted from `stream(...)` over the HTTP transport. Routes narrow this against the generated `ApiStream<P, N, V>` type at the call site. Bridge code (SSE writers, custom queue adapters) should treat it as opaque key/value.
212
+
213
+ ## Internal pipeline helpers
214
+
215
+ Not exported. Listed for traceability against the source.
216
+
217
+ | Helper | File | Role |
218
+ | --- | --- | --- |
219
+ | `validateApiMessage` | `handleApiRequest.ts` | Rejects malformed socket envelopes. |
220
+ | `checkApiAuth` | `handleApiRequest.ts` | Wraps `auth.login` gate + `validateRequest`. |
221
+ | `applyApiRateLimits` | `handleApiRequest.ts` | Per-route + global per-IP bucket. Fires `rateLimitExceeded` on reject. |
222
+ | `executeApiHandler` | `handleApiRequest.ts` | `tryCatch` + `startSpan` wrap around `apiEntry.main(...)`. |
223
+ | `buildApiResponseEnvelope` | `handleApiRequest.ts` | Normalizes success / error result to wire envelope with inferred `httpStatus`. |
224
+ | `emitApiResult` | `handleApiRequest.ts` | Runs `preApiRespond` / `transformApiResponse` / `postApiRespond` hooks and emits on the socket. |
225
+ | `warnIfInputTypeMissing` | both | Dev-only one-shot warning when a route lacks a generated `inputType`. |
226
+ | `runHandleHttpApiRequestInner` | `handleHttpApiRequest.ts` | The core inner pipeline for the HTTP transport. |
227
+ | `isRuntimeApiResult` | `handleHttpApiRequest.ts` | Type guard rejecting handler returns whose `status` is not `'success'` / `'error'`. |
228
+
229
+ ## Hooks dispatched
230
+
231
+ In pipeline order:
232
+
233
+ | Hook | Stoppable | Payload (key fields) |
234
+ | --- | --- | --- |
235
+ | `preApiValidate` | yes | `routeName`, `data`, `user` |
236
+ | `postApiValidate` | no | `routeName`, `data`, `user`, `validation: { status, message? }` |
237
+ | `preApiExecute` | yes | `routeName`, `data`, `user` |
238
+ | `postApiExecute` | no | `routeName`, `data`, `user`, `result`, `error`, `durationMs` |
239
+ | `preApiRespond` | no (mutates `response`) | `routeName`, `user`, `response: ApiResponseEnvelope` |
240
+ | `transformApiResponse` | no (mutates `response`) | `routeName`, `user`, `response` |
241
+ | `postApiRespond` | no | `routeName`, `user`, `response` |
242
+ | `rateLimitExceeded` | no | `scope: 'route' \| 'user' \| 'ip'`, `key`, `limit`, `windowMs`, `count`, `route?`, `userId?`, `ip?` |
243
+
244
+ Handlers register via `registerHook(name, handler)` from `@luckystack/core`. Stop signals are `{ stop: true, errorCode, httpStatus? }`. For deeper hook contract details see `@luckystack/core/docs/hooks.md`.
245
+
246
+ ## Config keys
247
+
248
+ Read at request time via `getProjectConfig()`. Never captured at module load.
249
+
250
+ | Key | Type | Effect |
251
+ | --- | --- | --- |
252
+ | `logging.devLogs` | `boolean` | Gates verbose per-request `getLogger().debug/warn/error` output. |
253
+ | `logging.stream` | `boolean` | Gates `emitStream` payload logging. |
254
+ | `dev.warnOnMissingInputType` | `boolean` | Toggles the one-shot `warnIfInputTypeMissing` developer hint. |
255
+ | `rateLimiting.defaultApiLimit` | `number \| false` | Per-route fallback when the route does not export `rateLimit`. `false` disables the per-route bucket. |
256
+ | `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP bucket across all API routes. `false` disables. |
257
+ | `rateLimiting.windowMs` | `number` | Window size for both buckets. |
258
+
259
+ No environment variables are read directly by this package. Secrets and toggles travel through `@luckystack/core`'s config registry.
260
+
261
+ ## Edge cases
262
+
263
+ - **Missing `responseIndex` (socket).** Returns silently after a dev-log warning. The client will never get a response — by design, since we have no channel to send it on.
264
+ - **Malformed `msg` (socket).** Returns silently if `msg` is not an object. Logs to dev-log.
265
+ - **Unknown route after `parseTransportRouteName` succeeds.** Returns `api.notFound` with the original (pre-normalization) name in `errorParams[0].value` so audit handlers can correlate.
266
+ - **Empty handler return.** Treated as an error: `api.emptyResponse` + `500`. Handlers must return an `{ status: 'success' }` or `{ status: 'error' }` object.
267
+ - **Invalid `status` from handler.** Treated as an error: `api.invalidResponseStatus` + `500`.
268
+ - **`preApiRespond` stop signal.** The original envelope is discarded; a fresh error envelope is built from the signal's `errorCode` + `httpStatus`. The replacement envelope still flows through `transformApiResponse` and `postApiRespond`.
269
+ - **Streaming + error.** If `main(...)` throws after one or more `stream(...)` calls, the stream events were already on the wire — the final envelope is the error response. SSE clients must handle the mixed sequence.
270
+
271
+ ## Related
272
+
273
+ - README: [`../README.md`](../README.md)
274
+ - Routing conventions: [`./routing-conventions.md`](./routing-conventions.md)
275
+ - Auth flow: [`./auth-flow.md`](./auth-flow.md)
276
+ - Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
277
+ - Error handling: [`./error-handling.md`](./error-handling.md)
278
+ - Generated types: [`./generated-types.md`](./generated-types.md)
279
+ - Architecture deep-dive: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md)
280
+ - Hook contract: `@luckystack/core/docs/hooks.md`