@luckystack/api 0.10.2 → 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.
- package/AGENTS.md +7 -0
- package/CHANGELOG.md +6 -1
- package/README.md +2 -103
- package/dist/_shared/apiTypes.d.ts +68 -0
- package/dist/_shared/applyApiRateLimits.d.ts +30 -0
- package/dist/_shared/backpressure.d.ts +4 -0
- package/dist/_shared/httpValidationStage.d.ts +14 -0
- package/dist/_shared/inputTypeWarning.d.ts +1 -0
- package/dist/_shared/logFlags.d.ts +4 -0
- package/dist/_shared/rateLimitIdentity.d.ts +1 -0
- package/dist/_shared/requestLifecycle.d.ts +14 -0
- package/dist/_shared/responseEnvelope.d.ts +9 -0
- package/dist/_shared/socketValidationStage.d.ts +13 -0
- package/dist/handleApiRequest.d.ts +9 -0
- package/dist/handleHttpApiRequest.d.ts +60 -0
- package/dist/index.d.ts +3 -72
- package/dist/index.js.map +1 -1
- package/docs/api-request-lifecycle.md +3 -276
- package/docs/auth-flow.md +3 -228
- package/docs/error-handling.md +3 -242
- package/docs/generated-types.md +3 -260
- package/docs/rate-limiting.md +3 -184
- package/docs/routing-conventions.md +3 -160
- package/package.json +4 -4
- package/CLAUDE.md +0 -111
|
@@ -1,278 +1,5 @@
|
|
|
1
|
-
# API
|
|
1
|
+
# API request lifecycle
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Socket and HTTP entry points should enforce the same route-level authorization, limits, input-validation and response policies; a difference in transport must not open a bypass. The order matters where it protects information: resolve the route to find its policy, then reject unauthorized callers before exposing input-schema or HTTP-method details. Route names themselves are not secret; clients receive them in generated artifacts. See `../src/handleApiRequest.ts`, `../src/handleHttpApiRequest.ts`, `../src/transportParity.test.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
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.
|
|
8
|
-
|
|
9
|
-
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).
|
|
10
|
-
|
|
11
|
-
## Pipeline order (13 steps)
|
|
12
|
-
|
|
13
|
-
Both transports execute the same sequence. The HTTP transport adds one extra step (method check).
|
|
14
|
-
|
|
15
|
-
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.
|
|
16
|
-
2. **`readSession(token)` → resolve `user`.** Reads the session via `@luckystack/core`'s session-provider registry (`readSession`). Preferred locale is also extracted here from `x-language` / `accept-language` headers using `extractLanguageFromHeader`. Error-tracker identity is propagated to registered trackers via `setCurrentErrorTrackerIdentity` (ALS-based per-request scope opened by `runWithErrorTrackerIdentityScope`) — NOT a direct `setSentryUser` call; that moved to hook subscribers in `@luckystack/error-tracking` (`preApiExecute` / `preApiValidate`).
|
|
17
|
-
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`.
|
|
18
|
-
4. **Built-in `system/logout` shortcut (socket only).** When the parsed route normalizes to `system/logout`, the handler applies the global per-IP rate-limit bucket, then calls `performLogout({ token, socket, userId })` and short-circuits with a `{ status: 'success', httpStatus: 200, result: true }` envelope routed through `emitApiResult` (so `preApiRespond`, `transformApiResponse`, and `postApiRespond` still fire). **Hooks that are skipped for logout:** `checkApiAuth` (no auth gate), `preApiValidate`, `postApiValidate`, `preApiExecute`, `postApiExecute`. HTTP transport does not have this shortcut — logout over HTTP goes through the normal route execution.
|
|
19
|
-
5. **`getRuntimeApiMaps()` + route lookup.** Reads the registered `apisObject` / `functionsObject`. Missing routes return `api.notFound` with `404`.
|
|
20
|
-
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).
|
|
21
|
-
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).
|
|
22
|
-
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.
|
|
23
|
-
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 the generic `api.invalidInputType` code — the validator's message is NOT echoed to the client (that would let unauthenticated callers enumerate the input schema); it travels to the `postApiValidate` hook (`validation.message`) and the dev logs instead. `postApiValidate` always fires with the resulting validation result, even on skip.
|
|
24
|
-
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`).
|
|
25
|
-
11. **`executeApiHandler(...)`.** Wraps `apiEntry.main({ data, user, functions, stream })` in `tryCatch` (which captures any thrown error to the active `@luckystack/error-tracking` adapter). Records `durationMs`. Span open/close and user-identity propagation are handled by hook subscribers registered in `@luckystack/error-tracking` (via `preApiExecute` / `postApiExecute`) — NOT by a direct `startSpan` or `setSentryUser` call inside this handler.
|
|
26
|
-
12. **`postApiExecute` hook.** Observation point. Receives `result`, `error`, and `durationMs`. Use for audit logging, metrics, or post-run side effects. Cannot mutate the response.
|
|
27
|
-
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.
|
|
28
|
-
|
|
29
|
-
## API Reference
|
|
30
|
-
|
|
31
|
-
### `handleApiRequest({ msg, socket, token })`
|
|
32
|
-
|
|
33
|
-
Socket.io transport adapter. Default export.
|
|
34
|
-
|
|
35
|
-
**Signature**
|
|
36
|
-
|
|
37
|
-
```ts
|
|
38
|
-
import handleApiRequest from '@luckystack/api';
|
|
39
|
-
import type { apiMessage } from '@luckystack/core';
|
|
40
|
-
import type { Socket } from 'socket.io';
|
|
41
|
-
|
|
42
|
-
export default async function handleApiRequest(args: {
|
|
43
|
-
msg: apiMessage;
|
|
44
|
-
socket: Socket;
|
|
45
|
-
token: string | null;
|
|
46
|
-
}): Promise<void>;
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
**Parameters**
|
|
50
|
-
|
|
51
|
-
| Field | Type | Notes |
|
|
52
|
-
| --- | --- | --- |
|
|
53
|
-
| `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). |
|
|
54
|
-
| `socket` | `Socket` | Live socket.io socket. Used for `socket.emit`, `socket.handshake.headers`, and `socket.handshake.address` (for the global IP rate-limit bucket). |
|
|
55
|
-
| `token` | `string \| null` | Session token from the socket handshake (`extractTokenFromSocket`). Drives `getSession`, the per-route rate-limit bucket key, and the `system/logout` shortcut. |
|
|
56
|
-
|
|
57
|
-
**Returns**
|
|
58
|
-
|
|
59
|
-
`Promise<void>`. The response is delivered by `socket.emit(buildApiResponseEventName(responseIndex), envelope)`. The function resolves once `postApiRespond` has completed.
|
|
60
|
-
|
|
61
|
-
**Behavior highlights**
|
|
62
|
-
|
|
63
|
-
- Treats `responseIndex` as the response correlation token; clients listen on `buildApiResponseEventName(responseIndex)`.
|
|
64
|
-
- Reads `x-language` / `accept-language` from `socket.handshake.headers` for locale extraction.
|
|
65
|
-
- 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).
|
|
66
|
-
- Built-in route: `system/logout` exits with a success envelope after calling `logout(...)` from `@luckystack/login`.
|
|
67
|
-
- 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.
|
|
68
|
-
|
|
69
|
-
**Errors / framework-emitted error codes**
|
|
70
|
-
|
|
71
|
-
| `errorCode` | When | HTTP status |
|
|
72
|
-
| --- | --- | --- |
|
|
73
|
-
| `api.invalidRequest` | `name` or `data` missing / wrong type. | 400 |
|
|
74
|
-
| `routing.invalidServiceRouteName` | `name` is not `api/<page>/<name>/v<N>`. | 400 |
|
|
75
|
-
| `api.notFound` | Route not registered in runtime maps. | 404 |
|
|
76
|
-
| `auth.required` | `auth.login` is `true` and `user?.id` is missing. | 401 |
|
|
77
|
-
| `auth.forbidden` | `auth.additional[]` predicate rejects. | 403 |
|
|
78
|
-
| `auth.invalidCondition` | `auth.additional[]` references a session key that is not on the session shape. Setup error. | 500 |
|
|
79
|
-
| `api.rateLimitExceeded` | Per-route or per-IP bucket rejects. `errorParams[0].value` is `resetIn` (seconds). | 429 |
|
|
80
|
-
| `api.invalidInputType` | `validateInputByType` rejects the payload. Generic code only — the validator's message is routed to the `postApiValidate` hook + dev logs, never echoed to the client (schema-enumeration guard). | 400 |
|
|
81
|
-
| `api.internalServerError` | `main(...)` threw. | 500 |
|
|
82
|
-
| `api.emptyResponse` | `main(...)` returned `null` or `undefined`. | 500 |
|
|
83
|
-
| `api.invalidResponseStatus` | Handler returned an object whose `status` is neither `'success'` nor `'error'`. | 500 |
|
|
84
|
-
|
|
85
|
-
**Example**
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
import handleApiRequest from '@luckystack/api';
|
|
89
|
-
import { extractTokenFromSocket } from '@luckystack/core';
|
|
90
|
-
|
|
91
|
-
io.on('connection', (socket) => {
|
|
92
|
-
socket.on('apiRequest', (msg) => {
|
|
93
|
-
void handleApiRequest({
|
|
94
|
-
msg,
|
|
95
|
-
socket,
|
|
96
|
-
token: extractTokenFromSocket(socket),
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
### `handleHttpApiRequest({ name, data, token, ... })`
|
|
103
|
-
|
|
104
|
-
HTTP transport adapter. Same pipeline as the socket variant plus HTTP-method validation.
|
|
105
|
-
|
|
106
|
-
**Signature**
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
|
-
import { handleHttpApiRequest } from '@luckystack/api';
|
|
110
|
-
import type { HttpMethod } from '@luckystack/core';
|
|
111
|
-
|
|
112
|
-
export type ApiHttpStreamEvent = Record<string, unknown>;
|
|
113
|
-
|
|
114
|
-
export async function handleHttpApiRequest(params: {
|
|
115
|
-
name: string;
|
|
116
|
-
data: Record<string, unknown>;
|
|
117
|
-
token: string | null;
|
|
118
|
-
requesterIp?: string;
|
|
119
|
-
xLanguageHeader?: string | string[];
|
|
120
|
-
acceptLanguageHeader?: string | string[];
|
|
121
|
-
method?: HttpMethod;
|
|
122
|
-
stream?: (payload: ApiHttpStreamEvent) => void;
|
|
123
|
-
}): Promise<ApiNetworkResponse>;
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
**Parameters**
|
|
127
|
-
|
|
128
|
-
| Field | Type | Notes |
|
|
129
|
-
| --- | --- | --- |
|
|
130
|
-
| `name` | `string` | Route name. May be passed with or without the `api/` prefix; the handler normalizes both. |
|
|
131
|
-
| `data` | `Record<string, unknown>` | Request payload. Must be an object (arrays, primitives rejected). |
|
|
132
|
-
| `token` | `string \| null` | Session token from cookie or `Authorization: Bearer`. |
|
|
133
|
-
| `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. |
|
|
134
|
-
| `xLanguageHeader` | `string \| string[]` (optional) | Forwarded to `extractLanguageFromHeader`. Takes precedence over `acceptLanguageHeader`. |
|
|
135
|
-
| `acceptLanguageHeader` | `string \| string[]` (optional) | Standard `Accept-Language` header. |
|
|
136
|
-
| `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`. |
|
|
137
|
-
| `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. |
|
|
138
|
-
|
|
139
|
-
**Returns**
|
|
140
|
-
|
|
141
|
-
`Promise<ApiNetworkResponse>` — exactly one of:
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
type ApiNetworkResponse =
|
|
145
|
-
| ({ status: 'success'; httpStatus: number } & Record<string, unknown>)
|
|
146
|
-
| {
|
|
147
|
-
status: 'error';
|
|
148
|
-
httpStatus: number;
|
|
149
|
-
message: string;
|
|
150
|
-
errorCode: string;
|
|
151
|
-
errorParams?: { key: string; value: string | number | boolean }[];
|
|
152
|
-
};
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
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).
|
|
156
|
-
|
|
157
|
-
**Behavior highlights**
|
|
158
|
-
|
|
159
|
-
- Same auth → rate-limit → validate → execute → respond order as the socket variant, with method check inserted between rate-limit and validate.
|
|
160
|
-
- 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.
|
|
161
|
-
- 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.
|
|
162
|
-
- 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>`.
|
|
163
|
-
- The function dispatches `preApiRespond` and `postApiRespond` in `handleHttpApiRequest` itself (wrapping the inner runner), so the same response-mutation contract applies to both transports.
|
|
164
|
-
|
|
165
|
-
**Errors / framework-emitted error codes**
|
|
166
|
-
|
|
167
|
-
Same set as the socket variant, plus:
|
|
168
|
-
|
|
169
|
-
| `errorCode` | When | HTTP status |
|
|
170
|
-
| --- | --- | --- |
|
|
171
|
-
| `api.invalidName` | `name` missing or not a string. | 400 |
|
|
172
|
-
| `api.invalidDataObject` | `data` missing, null, or not an object. | 400 |
|
|
173
|
-
| `api.methodNotAllowed` | Request method does not match the route's declared or inferred HTTP method. `errorParams[0].value` is the expected method. | 405 |
|
|
174
|
-
|
|
175
|
-
**Example**
|
|
176
|
-
|
|
177
|
-
```ts
|
|
178
|
-
import { handleHttpApiRequest } from '@luckystack/api';
|
|
179
|
-
|
|
180
|
-
httpServer.on('request', async (req, res) => {
|
|
181
|
-
if (!req.url?.startsWith('/api/')) return;
|
|
182
|
-
|
|
183
|
-
const token = extractTokenFromRequest(req);
|
|
184
|
-
const body = await readJson(req);
|
|
185
|
-
const name = req.url.slice('/api/'.length).replace(/\?.*$/, '');
|
|
186
|
-
|
|
187
|
-
const result = await handleHttpApiRequest({
|
|
188
|
-
name,
|
|
189
|
-
data: body,
|
|
190
|
-
token,
|
|
191
|
-
requesterIp: req.socket.remoteAddress ?? undefined,
|
|
192
|
-
xLanguageHeader: req.headers['x-language'],
|
|
193
|
-
acceptLanguageHeader: req.headers['accept-language'],
|
|
194
|
-
method: req.method as HttpMethod,
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
res.statusCode = result.httpStatus;
|
|
198
|
-
res.setHeader('content-type', 'application/json');
|
|
199
|
-
res.end(JSON.stringify(result));
|
|
200
|
-
});
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
### Type: `ApiHttpStreamEvent`
|
|
204
|
-
|
|
205
|
-
```ts
|
|
206
|
-
export type ApiHttpStreamEvent = Record<string, unknown>;
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
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.
|
|
210
|
-
|
|
211
|
-
## Internal pipeline helpers
|
|
212
|
-
|
|
213
|
-
Not exported. Listed for traceability against the source.
|
|
214
|
-
|
|
215
|
-
| Helper | File | Role |
|
|
216
|
-
| --- | --- | --- |
|
|
217
|
-
| `validateApiMessage` | `handleApiRequest.ts` | Rejects malformed socket envelopes. |
|
|
218
|
-
| `checkApiAuth` | `handleApiRequest.ts` | Wraps `auth.login` gate + `validateRequest`. |
|
|
219
|
-
| `applyApiRateLimits` | `handleApiRequest.ts` | Per-route + global per-IP bucket. Fires `rateLimitExceeded` on reject. |
|
|
220
|
-
| `executeApiHandler` | `handleApiRequest.ts` | `tryCatch` + `startSpan` wrap around `apiEntry.main(...)`. |
|
|
221
|
-
| `buildApiResponseEnvelope` | `handleApiRequest.ts` | Normalizes success / error result to wire envelope with inferred `httpStatus`. |
|
|
222
|
-
| `emitApiResult` | `handleApiRequest.ts` | Runs `preApiRespond` / `transformApiResponse` / `postApiRespond` hooks and emits on the socket. |
|
|
223
|
-
| `warnIfInputTypeMissing` | both | Dev-only one-shot warning when a route lacks a generated `inputType`. |
|
|
224
|
-
| `runHandleHttpApiRequestInner` | `handleHttpApiRequest.ts` | The core inner pipeline for the HTTP transport. |
|
|
225
|
-
| `isRuntimeApiResult` | `handleHttpApiRequest.ts` | Type guard rejecting handler returns whose `status` is not `'success'` / `'error'`. |
|
|
226
|
-
|
|
227
|
-
## Hooks dispatched
|
|
228
|
-
|
|
229
|
-
In pipeline order:
|
|
230
|
-
|
|
231
|
-
| Hook | Stoppable | Payload (key fields) |
|
|
232
|
-
| --- | --- | --- |
|
|
233
|
-
| `preApiValidate` | yes | `routeName`, `data`, `user` |
|
|
234
|
-
| `postApiValidate` | no | `routeName`, `data`, `user`, `validation: { status, message? }` |
|
|
235
|
-
| `preApiExecute` | yes | `routeName`, `data`, `user` |
|
|
236
|
-
| `postApiExecute` | no | `routeName`, `data`, `user`, `result`, `error`, `durationMs` |
|
|
237
|
-
| `preApiRespond` | no (mutates `response`) | `routeName`, `user`, `response: ApiResponseEnvelope` |
|
|
238
|
-
| `transformApiResponse` | no (mutates `response`) | `routeName`, `user`, `response` |
|
|
239
|
-
| `postApiRespond` | no | `routeName`, `user`, `response` |
|
|
240
|
-
| `rateLimitExceeded` | no | `scope: 'route' \| 'user' \| 'ip'`, `key`, `limit`, `windowMs`, `count`, `route?`, `userId?`, `ip?` |
|
|
241
|
-
|
|
242
|
-
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`.
|
|
243
|
-
|
|
244
|
-
## Config keys
|
|
245
|
-
|
|
246
|
-
Read at request time via `getProjectConfig()`. Never captured at module load.
|
|
247
|
-
|
|
248
|
-
| Key | Type | Effect |
|
|
249
|
-
| --- | --- | --- |
|
|
250
|
-
| `logging.devLogs` | `boolean` | Gates verbose per-request `getLogger().debug/warn/error` output. |
|
|
251
|
-
| `logging.stream` | `boolean` | Gates `emitStream` payload logging. |
|
|
252
|
-
| `dev.warnOnMissingInputType` | `boolean` | Toggles the one-shot `warnIfInputTypeMissing` developer hint. |
|
|
253
|
-
| `rateLimiting.defaultApiLimit` | `number \| false` | Per-route fallback when the route does not export `rateLimit`. `false` disables the per-route bucket. |
|
|
254
|
-
| `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP bucket across all API routes. `false` disables. |
|
|
255
|
-
| `rateLimiting.windowMs` | `number` | Window size for both buckets. |
|
|
256
|
-
|
|
257
|
-
No environment variables are read directly by this package. Secrets and toggles travel through `@luckystack/core`'s config registry.
|
|
258
|
-
|
|
259
|
-
## Edge cases
|
|
260
|
-
|
|
261
|
-
- **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.
|
|
262
|
-
- **Malformed `msg` (socket).** Returns silently if `msg` is not an object. Logs to dev-log.
|
|
263
|
-
- **Unknown route after `parseTransportRouteName` succeeds.** Returns `api.notFound` with the original (pre-normalization) name in `errorParams[0].value` so audit handlers can correlate.
|
|
264
|
-
- **Empty handler return.** Treated as an error: `api.emptyResponse` + `500`. Handlers must return an `{ status: 'success' }` or `{ status: 'error' }` object.
|
|
265
|
-
- **Invalid `status` from handler.** Treated as an error: `api.invalidResponseStatus` + `500`.
|
|
266
|
-
- **`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`.
|
|
267
|
-
- **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.
|
|
268
|
-
|
|
269
|
-
## Related
|
|
270
|
-
|
|
271
|
-
- README: [`../README.md`](../README.md)
|
|
272
|
-
- Routing conventions: [`./routing-conventions.md`](./routing-conventions.md)
|
|
273
|
-
- Auth flow: [`./auth-flow.md`](./auth-flow.md)
|
|
274
|
-
- Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
|
|
275
|
-
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
276
|
-
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
277
|
-
- Architecture deep-dive: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md)
|
|
278
|
-
- Hook contract: `@luckystack/core/docs/hooks.md`
|
|
5
|
+
Hooks may observe or change the response only at their designated seam. Do not leak thrown exceptions or raw schema diagnostics into protected responses. A streaming response may have already emitted chunks when a later operation fails; consumers must handle a final error as distinct from prior progress. See `../src/_shared/requestLifecycle.ts`, `../src/_shared/responseEnvelope.ts`, `./error-handling.md`.
|
package/docs/auth-flow.md
CHANGED
|
@@ -1,230 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# API authentication
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
API reads the session from core/login, then applies each route's authorization policy. Auth is not a property of transport: sockets and HTTP must both enforce it, and HTTP method declarations do not restrict socket calls. Route lookup must happen to discover auth policy; do not promise to conceal route names, which are also published in client maps. What must remain private is predicate detail, schema diagnostics and protected data. See `../src/handleApiRequest.ts`, `../src/handleHttpApiRequest.ts`, `../../core/src/validateRequest.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Auth runs **early** in the pipeline (step 2 + step 6 of the 13-step lifecycle), and it runs **before** rate limit, method check, and input validation. The ordering is deliberate: unauthenticated callers should not be able to enumerate routes via 404 vs 405 vs 400 timing, or learn input shapes via `inputValidation.message`. A failing auth gate always returns a generic `auth.required` (or `auth.forbidden`) before any of those signals leak.
|
|
8
|
-
|
|
9
|
-
The auth gate has two stages: a login check (cheap, boolean — does the session have a user ID?), then a predicate check (`validateRequest` evaluates `auth.additional[]`). Each can fail with a distinct error code + HTTP status. The HTTP transport also runs a defensive `if (!user)` check after the login gate to catch the edge case where `auth.login === false` was declared but downstream code still expects a user.
|
|
10
|
-
|
|
11
|
-
## Lifecycle stages owning auth
|
|
12
|
-
|
|
13
|
-
| Step | What happens |
|
|
14
|
-
| --- | --- |
|
|
15
|
-
| 2 (early) | `readSession(token)` → resolve `user`. Read once per request; downstream code reads `user` as a closure variable. Error-tracker identity set via `setCurrentErrorTrackerIdentity` (ALS scope), not a direct `setSentryUser` call. |
|
|
16
|
-
| 6 | `checkApiAuth({ apiEntry, user, name, emitApiError })` (or the inline HTTP block) runs the login + predicate gate. Rejection short-circuits the pipeline. |
|
|
17
|
-
| 4 (socket only) | `system/logout` shortcut. When the route normalizes to `system/logout`, the handler applies the global per-IP rate-limit, calls `performLogout(...)` directly, and emits a success envelope via `emitApiResult` — bypassing the runtime-map lookup, the auth gate, input validation, and the `preApiExecute`/`postApiExecute` hooks. The respond-phase hooks (`preApiRespond`, `transformApiResponse`, `postApiRespond`) still fire. |
|
|
18
|
-
|
|
19
|
-
## API Reference
|
|
20
|
-
|
|
21
|
-
### `readSession(token)` (from `@luckystack/core`)
|
|
22
|
-
|
|
23
|
-
Read at request time via `@luckystack/core`'s session-provider registry. Returns `Promise<SessionLayout | null>`. Reads from the active session adapter (Redis by default); applies sliding-expiration (dispatches `preSessionRefresh` / `postSessionRefresh` hooks).
|
|
24
|
-
|
|
25
|
-
A `null` return means: no session token, an expired session, a deleted session, or a session-adapter outage. The handler treats all four identically — `user` is `null` for the rest of the request, and `auth.login: true` rejects with `auth.required`.
|
|
26
|
-
|
|
27
|
-
### Error-tracker identity propagation
|
|
28
|
-
|
|
29
|
-
Called immediately after `readSession`. The handler calls `setCurrentErrorTrackerIdentity({ id, email, username })` (or `null` for unauthenticated requests) on the per-request ALS scope opened by `runWithErrorTrackerIdentityScope`. This replaces the former direct `setSentryUser` call — identity now flows through the ALS box, which hook subscribers in `@luckystack/error-tracking` read at capture time. The effect is identical: every `captureException` / `captureMessage` during this request is attributed to the correct user, even under concurrent requests.
|
|
30
|
-
|
|
31
|
-
### `checkApiAuth(...)` (internal helper, socket transport)
|
|
32
|
-
|
|
33
|
-
**Signature**:
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
const checkApiAuth = (params: {
|
|
37
|
-
apiEntry: RuntimeApiEntry;
|
|
38
|
-
user: SessionLayout | null;
|
|
39
|
-
name: string; // The pre-normalization route name; used for log context.
|
|
40
|
-
emitApiError: EmitApiError;
|
|
41
|
-
}): boolean;
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**Behavior**:
|
|
45
|
-
|
|
46
|
-
1. If `apiEntry.auth.login === true` and `user?.id` is missing, emit `auth.required` + `401`. Return `false`.
|
|
47
|
-
2. Otherwise, run `validateRequest({ auth: apiEntry.auth, user: user! })`.
|
|
48
|
-
3. If the result is `{ status: 'error', errorCode, errorParams, httpStatus }`, emit it (defaulting to `auth.forbidden` + `403` when fields are missing). Return `false`.
|
|
49
|
-
4. Otherwise, return `true`.
|
|
50
|
-
|
|
51
|
-
The function logs auth failures at `warn` level when `logging.devLogs` is on.
|
|
52
|
-
|
|
53
|
-
### Inline auth block in `handleHttpApiRequest`
|
|
54
|
-
|
|
55
|
-
The HTTP transport inlines the same logic but adds a defensive `if (!user)` check between the login gate and the predicate gate:
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
if (auth.login && !user?.id) {
|
|
59
|
-
return buildNetworkError({ response: { status: 'error', errorCode: 'auth.required' }, fallbackHttpStatus: 401 });
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (!user) {
|
|
63
|
-
return buildNetworkError({ response: { status: 'error', errorCode: 'auth.forbidden' }, fallbackHttpStatus: 403 });
|
|
64
|
-
}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
This catches the configuration drift where a route declared `auth.login: false` but the predicate evaluator (`validateRequest`) still expects a non-null user (its parameter type is `BaseSessionLayout`, not `BaseSessionLayout | null`). In practice this only matters if you opt out of login but still declare `auth.additional[]` checks — in that case, `validateRequest` would dereference `null`. The guard short-circuits with `auth.forbidden` first.
|
|
68
|
-
|
|
69
|
-
### `validateRequest({ auth, user })` (from `@luckystack/core`)
|
|
70
|
-
|
|
71
|
-
The predicate evaluator.
|
|
72
|
-
|
|
73
|
-
**Signature**:
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
function validateRequest(params: {
|
|
77
|
-
auth: AuthProps;
|
|
78
|
-
user: BaseSessionLayout;
|
|
79
|
-
}): {
|
|
80
|
-
status: 'success' | 'error';
|
|
81
|
-
errorCode?: string;
|
|
82
|
-
errorParams?: { key: string; value: string | number | boolean }[];
|
|
83
|
-
httpStatus?: number;
|
|
84
|
-
};
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
**Behavior**: walks `auth.additional[]` (if present). Each entry references a `key` on the session object and adds up to four AND'd constraints:
|
|
88
|
-
|
|
89
|
-
| Constraint | Semantics |
|
|
90
|
-
| --- | --- |
|
|
91
|
-
| `nullish: true` | `user[key]` must be `null` or `undefined`. |
|
|
92
|
-
| `nullish: false` | `user[key]` must NOT be `null` or `undefined`. |
|
|
93
|
-
| `type: 'string' \| 'number' \| 'boolean'` | `typeof user[key]` must match. Skipped if `user[key]` is null/undefined. |
|
|
94
|
-
| `value: <x>` | Strict equality `user[key] === x`. Use `'value' in condition` semantics: omit the key entirely if you don't want the check (setting `value: undefined` explicitly means "must be undefined"). |
|
|
95
|
-
| `mustBeFalsy: true` | `isFalsy(user[key])`. |
|
|
96
|
-
| `mustBeFalsy: false` | `!isFalsy(user[key])`. |
|
|
97
|
-
|
|
98
|
-
Multiple constraints on a single entry are AND'd. Multiple entries in the array are also AND'd — every entry must pass.
|
|
99
|
-
|
|
100
|
-
**Errors**:
|
|
101
|
-
|
|
102
|
-
| Condition | Returns |
|
|
103
|
-
| --- | --- |
|
|
104
|
-
| `condition.key` does not exist on the session object | `{ errorCode: 'auth.invalidCondition', errorParams: [{ key: 'key', value: <missingKey> }], httpStatus: 500 }`. This is a setup error, not a runtime auth fail — a route asked for a session field the project's `SessionLayout` does not declare. |
|
|
105
|
-
| Any constraint fails | `{ errorCode: 'auth.forbidden', errorParams: [{ key: 'key', value: <failingKey> }], httpStatus: 403 }`. |
|
|
106
|
-
| All constraints pass | `{ status: 'success' }`. |
|
|
107
|
-
|
|
108
|
-
### `logout({ token, socket, userId })` (from `@luckystack/login`)
|
|
109
|
-
|
|
110
|
-
Only reachable via the `system/logout` socket shortcut. Calls `deleteSession(token)` (which dispatches `preSessionDelete` → adapter delete + active-user untrack → `postSessionDelete`), emits `socketEventNames.logout` to the socket, and leaves the socket room.
|
|
111
|
-
|
|
112
|
-
The shortcut bypasses: runtime-map lookup, the per-route auth gate, per-route rate limits, input validation, and the execution hooks (`preApiExecute`, `postApiExecute`). The global per-IP rate-limit bucket IS applied. The respond-phase hooks (`preApiRespond`, `transformApiResponse`, `postApiRespond`) ARE dispatched via `emitApiResult`. Logout is a built-in, not a user-defined route.
|
|
113
|
-
|
|
114
|
-
## `AuthProps` shape
|
|
115
|
-
|
|
116
|
-
```ts
|
|
117
|
-
interface AuthProps {
|
|
118
|
-
/** If true, user must have a valid session with an ID. */
|
|
119
|
-
login: boolean;
|
|
120
|
-
|
|
121
|
-
/** Additional validation rules for session properties. */
|
|
122
|
-
additional?: {
|
|
123
|
-
key: keyof BaseSessionLayout; // The session field to inspect (e.g. 'admin').
|
|
124
|
-
value?: unknown; // Strict equality.
|
|
125
|
-
type?: 'string' | 'number' | 'boolean';
|
|
126
|
-
nullish?: boolean;
|
|
127
|
-
mustBeFalsy?: boolean;
|
|
128
|
-
}[];
|
|
129
|
-
}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Project code typically declares `AuthProps` like:
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
// src/admin/_api/banUser_v1.ts
|
|
136
|
-
export const auth: AuthProps = {
|
|
137
|
-
login: true,
|
|
138
|
-
additional: [
|
|
139
|
-
{ key: 'admin', value: true },
|
|
140
|
-
],
|
|
141
|
-
};
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
`additional[].key` is constrained by `keyof BaseSessionLayout` — the project's `SessionLayout` extends `BaseSessionLayout`, so any field the project adds (e.g. `admin: boolean`, `organizationId: string`) is type-checkable. Referencing a key the session does not declare is a TypeScript error at compile time and a `500` `auth.invalidCondition` at runtime.
|
|
145
|
-
|
|
146
|
-
## Error codes
|
|
147
|
-
|
|
148
|
-
| Code | When | HTTP status |
|
|
149
|
-
| --- | --- | --- |
|
|
150
|
-
| `auth.required` | `auth.login === true` and `user?.id` is missing. | 401 |
|
|
151
|
-
| `auth.forbidden` | `auth.additional[]` predicate rejects, or the HTTP-transport defensive `if (!user)` fires. | 403 |
|
|
152
|
-
| `auth.invalidCondition` | `auth.additional[].key` is not present on the session shape. Setup error. | 500 |
|
|
153
|
-
| Custom `errorCode` from `validateRequest` | Custom predicate (none ship by default) overrides the fallback. | Custom `httpStatus`, default 403. |
|
|
154
|
-
|
|
155
|
-
All four codes go through `normalizeErrorResponse` so the localized normalizer can render a translated message (see [`./error-handling.md`](./error-handling.md)).
|
|
156
|
-
|
|
157
|
-
## Ordering: why auth runs before rate limit
|
|
158
|
-
|
|
159
|
-
Auth is step 6 of 13. Rate limit is step 7. The comment in `handleApiRequest.ts` puts it bluntly:
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
//? Auth → rate-limit → validate → execute → respond.
|
|
163
|
-
//? Auth runs before validate so unauthenticated probes can't enumerate
|
|
164
|
-
//? routes or learn input shape from `inputValidation.message`.
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
In practice this means:
|
|
168
|
-
|
|
169
|
-
- An anonymous caller hitting a `login: true` route gets `auth.required` for every request, regardless of the route's input shape, regardless of whether the route exists.
|
|
170
|
-
- An anonymous caller hitting a route that does **not** exist gets the SAME `auth.required` (because the route lookup is step 5, before auth at step 6, but the runtime map check returns `api.notFound` early — see edge case below).
|
|
171
|
-
|
|
172
|
-
Wait — re-reading the pipeline: step 5 (`getRuntimeApiMaps + route lookup`) DOES run before auth. That is intentional: route lookup is needed to read `apiEntry.auth`. An unknown route returns `api.notFound` to the anonymous caller. That is a known leak (route names are enumerable by an anonymous caller); deliberate, since route names are not secret (the client bundle ships them anyway). The defense is at the input-shape and `auth.forbidden` boundary, where predicate details and validation messages would otherwise leak.
|
|
173
|
-
|
|
174
|
-
## Authorization extension points
|
|
175
|
-
|
|
176
|
-
The auth gate's predicate engine is intentionally small. Two ways to add custom behavior:
|
|
177
|
-
|
|
178
|
-
1. **Session-field predicates via `auth.additional[]`.** Add fields to your project's `SessionLayout` (via `BaseSessionLayout` module augmentation) and reference them by key. Examples: `admin`, `organizationId`, `plan`, `verified`. The session is populated by `@luckystack/login`'s adapter system — see `@luckystack/login` docs.
|
|
179
|
-
|
|
180
|
-
2. **Hook-based auth via `preApiExecute`.** Register a hook that inspects `user` + `routeName` + `data` and returns a stop signal:
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
registerHook('preApiExecute', async ({ routeName, user, data }) => {
|
|
184
|
-
if (routeName.startsWith('admin/') && !user?.admin) {
|
|
185
|
-
return { stop: true, errorCode: 'auth.forbidden', httpStatus: 403 };
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
`preApiExecute` runs after the per-route `AuthProps` gate, so use it for cross-cutting rules (admin gate across many routes, organization scoping, feature flags) rather than per-route checks.
|
|
191
|
-
|
|
192
|
-
For more complex authorization (RBAC, per-resource ACLs), keep the framework gate at the coarse-grained level (`login: true`, `admin: true`) and run fine-grained checks inside `main(...)`.
|
|
193
|
-
|
|
194
|
-
## Hooks dispatched
|
|
195
|
-
|
|
196
|
-
The auth gate itself does not dispatch hooks. Adjacent hooks that observe auth state:
|
|
197
|
-
|
|
198
|
-
| Hook | When | Payload |
|
|
199
|
-
| --- | --- | --- |
|
|
200
|
-
| `preSessionRefresh` / `postSessionRefresh` | Inside `getSession`, on every authenticated request | `{ token, oldTtl, newTtl }`. `preSessionRefresh` can stop the TTL extend. |
|
|
201
|
-
| `preApiExecute` | After the `AuthProps` gate, before `main(...)` | `{ routeName, data, user }`. Can stop with `{ errorCode, httpStatus }` for custom authorization. |
|
|
202
|
-
|
|
203
|
-
See [`./api-request-lifecycle.md`](./api-request-lifecycle.md) for the full hook order.
|
|
204
|
-
|
|
205
|
-
## Config keys
|
|
206
|
-
|
|
207
|
-
This package does not read any auth-specific config. `@luckystack/login` owns session policy:
|
|
208
|
-
|
|
209
|
-
- `session.expiryDays` (TTL for `getSession` sliding-extend).
|
|
210
|
-
- `session.perUser`, `session.maxConcurrentPerUser`, `session.onConflict` (single-session enforcement on new login).
|
|
211
|
-
- `auth.passwordPolicy.*` (credentials register / change paths).
|
|
212
|
-
- `auth.forgotPassword` (framework vs custom vs disabled).
|
|
213
|
-
|
|
214
|
-
Read those in `@luckystack/login/CLAUDE.md`.
|
|
215
|
-
|
|
216
|
-
## Edge cases
|
|
217
|
-
|
|
218
|
-
- **`user.id` is `0` or `''`.** The check is `!user?.id` — both falsy values reject with `auth.required`. Session adapters should never write a falsy ID; if you customize the user adapter, ensure IDs are truthy.
|
|
219
|
-
- **`auth.login: false` + `auth.additional[]` declared.** The login gate passes (no ID required), but the predicate engine receives a possibly-null user. The socket transport asserts `user!` and relies on `validateRequest`'s nullish guards (constraints are skipped when `user[key]` is null/undefined). The HTTP transport adds the defensive `if (!user)` short-circuit. Don't rely on this combination — declare `login: true` if you need session field checks.
|
|
220
|
-
- **Session deleted between `getSession` and `validateRequest`.** Not possible — `user` is captured once at step 2 and used as a closure. Subsequent revocations affect later requests, not this one.
|
|
221
|
-
- **`system/logout` race with concurrent requests on the same socket.** `logout` runs synchronously inside the API handler; concurrent requests on the same socket may have already resolved `getSession` before the logout completes. Those requests see a still-valid session. The next request to enter step 2 sees the deleted session.
|
|
222
|
-
- **OAuth callback or login HTTP routes.** Those live in `@luckystack/server`'s HTTP route table (`/auth/callback/<provider>`, `/auth/api/credentials`), not in `/api/*`. The auth gate documented here is for `/api/*` traffic only. Login endpoints have their own session-minting flow inside `@luckystack/login`.
|
|
223
|
-
|
|
224
|
-
## Related
|
|
225
|
-
|
|
226
|
-
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
227
|
-
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
228
|
-
- Session management: `@luckystack/login/docs/session-management.md`
|
|
229
|
-
- OAuth providers: `@luckystack/login/docs/oauth-providers.md`
|
|
230
|
-
- Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md), [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md)
|
|
5
|
+
Resolve user identity from the session, not the request payload. Keep error-tracker identity request-scoped so concurrent requests do not contaminate one another (`../../core/src/errorTrackerIdentity.ts`). The built-in logout shortcut is not an ordinary API route; do not assume route hooks alone observe or authorize it. See `../src/handleApiRequest.ts` and `../../login/src/logout.ts`.
|