@luckystack/api 0.1.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/CHANGELOG.md +14 -0
- package/CLAUDE.md +106 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +816 -0
- package/dist/index.js.map +1 -0
- package/docs/api-request-lifecycle.md +280 -0
- package/docs/auth-flow.md +232 -0
- package/docs/error-handling.md +247 -0
- package/docs/generated-types.md +264 -0
- package/docs/rate-limiting.md +188 -0
- package/docs/routing-conventions.md +164 -0
- package/package.json +66 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Auth Flow
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@luckystack/api` does not own authentication — it consumes `getSession` from `@luckystack/login` and `validateRequest` from `@luckystack/core`. The handler's job is to translate the per-route `AuthProps` declaration into the right gate (require login + optional predicate checks) and to attribute every framework error tracker call that follows to the right user.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## Lifecycle stages owning auth
|
|
14
|
+
|
|
15
|
+
| Step | What happens |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| 2 (early) | `getSession(token)` → `setSentryUser(user)`. Read once per request; downstream code reads `user` as a closure variable. |
|
|
18
|
+
| 6 | `checkApiAuth({ apiEntry, user, name, emitApiError })` (or the inline HTTP block) runs the login + predicate gate. Rejection short-circuits the pipeline. |
|
|
19
|
+
| 4 (socket only) | `system/logout` shortcut. When the route normalizes to `system/logout`, the handler calls `logout(...)` directly and emits a success envelope — bypassing the runtime-map lookup, the auth gate, and every subsequent step. |
|
|
20
|
+
|
|
21
|
+
## API Reference
|
|
22
|
+
|
|
23
|
+
### `getSession(token)` (from `@luckystack/login`)
|
|
24
|
+
|
|
25
|
+
Read at request time. Returns `Promise<SessionLayout | null>`. Reads from the active session adapter (Redis by default); applies sliding-expiration (dispatches `preSessionRefresh` / `postSessionRefresh` hooks).
|
|
26
|
+
|
|
27
|
+
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`.
|
|
28
|
+
|
|
29
|
+
### `setSentryUser(user)` (from `@luckystack/error-tracking`)
|
|
30
|
+
|
|
31
|
+
Called immediately after `getSession`. Records `{ id, email }` (or `null` for unauthenticated requests) on every active error tracker so that any subsequent `captureException` / `captureMessage` (including from inside `main(...)`) is attributed to this user.
|
|
32
|
+
|
|
33
|
+
### `checkApiAuth(...)` (internal helper, socket transport)
|
|
34
|
+
|
|
35
|
+
**Signature**:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const checkApiAuth = (params: {
|
|
39
|
+
apiEntry: RuntimeApiEntry;
|
|
40
|
+
user: SessionLayout | null;
|
|
41
|
+
name: string; // The pre-normalization route name; used for log context.
|
|
42
|
+
emitApiError: EmitApiError;
|
|
43
|
+
}): boolean;
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Behavior**:
|
|
47
|
+
|
|
48
|
+
1. If `apiEntry.auth.login === true` and `user?.id` is missing, emit `auth.required` + `401`. Return `false`.
|
|
49
|
+
2. Otherwise, run `validateRequest({ auth: apiEntry.auth, user: user! })`.
|
|
50
|
+
3. If the result is `{ status: 'error', errorCode, errorParams, httpStatus }`, emit it (defaulting to `auth.forbidden` + `403` when fields are missing). Return `false`.
|
|
51
|
+
4. Otherwise, return `true`.
|
|
52
|
+
|
|
53
|
+
The function logs auth failures at `warn` level when `logging.devLogs` is on.
|
|
54
|
+
|
|
55
|
+
### Inline auth block in `handleHttpApiRequest`
|
|
56
|
+
|
|
57
|
+
The HTTP transport inlines the same logic but adds a defensive `if (!user)` check between the login gate and the predicate gate:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
if (auth.login && !user?.id) {
|
|
61
|
+
return buildNetworkError({ response: { status: 'error', errorCode: 'auth.required' }, fallbackHttpStatus: 401 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!user) {
|
|
65
|
+
return buildNetworkError({ response: { status: 'error', errorCode: 'auth.forbidden' }, fallbackHttpStatus: 403 });
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
### `validateRequest({ auth, user })` (from `@luckystack/core`)
|
|
72
|
+
|
|
73
|
+
The predicate evaluator.
|
|
74
|
+
|
|
75
|
+
**Signature**:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
function validateRequest(params: {
|
|
79
|
+
auth: AuthProps;
|
|
80
|
+
user: BaseSessionLayout;
|
|
81
|
+
}): {
|
|
82
|
+
status: 'success' | 'error';
|
|
83
|
+
errorCode?: string;
|
|
84
|
+
errorParams?: { key: string; value: string | number | boolean }[];
|
|
85
|
+
httpStatus?: number;
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Behavior**: walks `auth.additional[]` (if present). Each entry references a `key` on the session object and adds up to four AND'd constraints:
|
|
90
|
+
|
|
91
|
+
| Constraint | Semantics |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `nullish: true` | `user[key]` must be `null` or `undefined`. |
|
|
94
|
+
| `nullish: false` | `user[key]` must NOT be `null` or `undefined`. |
|
|
95
|
+
| `type: 'string' \| 'number' \| 'boolean'` | `typeof user[key]` must match. Skipped if `user[key]` is null/undefined. |
|
|
96
|
+
| `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"). |
|
|
97
|
+
| `mustBeFalsy: true` | `isFalsy(user[key])`. |
|
|
98
|
+
| `mustBeFalsy: false` | `!isFalsy(user[key])`. |
|
|
99
|
+
|
|
100
|
+
Multiple constraints on a single entry are AND'd. Multiple entries in the array are also AND'd — every entry must pass.
|
|
101
|
+
|
|
102
|
+
**Errors**:
|
|
103
|
+
|
|
104
|
+
| Condition | Returns |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `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. |
|
|
107
|
+
| Any constraint fails | `{ errorCode: 'auth.forbidden', errorParams: [{ key: 'key', value: <failingKey> }], httpStatus: 403 }`. |
|
|
108
|
+
| All constraints pass | `{ status: 'success' }`. |
|
|
109
|
+
|
|
110
|
+
### `logout({ token, socket, userId })` (from `@luckystack/login`)
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
The shortcut bypasses every later step: runtime-map lookup, auth gate, rate limit, validation, hooks (no `preApiExecute` / `postApiExecute`), execution, and respond hooks. Logout is a built-in, not a user-defined route.
|
|
115
|
+
|
|
116
|
+
## `AuthProps` shape
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
interface AuthProps {
|
|
120
|
+
/** If true, user must have a valid session with an ID. */
|
|
121
|
+
login: boolean;
|
|
122
|
+
|
|
123
|
+
/** Additional validation rules for session properties. */
|
|
124
|
+
additional?: {
|
|
125
|
+
key: keyof BaseSessionLayout; // The session field to inspect (e.g. 'admin').
|
|
126
|
+
value?: unknown; // Strict equality.
|
|
127
|
+
type?: 'string' | 'number' | 'boolean';
|
|
128
|
+
nullish?: boolean;
|
|
129
|
+
mustBeFalsy?: boolean;
|
|
130
|
+
}[];
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Project code typically declares `AuthProps` like:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// src/admin/_api/banUser_v1.ts
|
|
138
|
+
export const auth: AuthProps = {
|
|
139
|
+
login: true,
|
|
140
|
+
additional: [
|
|
141
|
+
{ key: 'admin', value: true },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`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.
|
|
147
|
+
|
|
148
|
+
## Error codes
|
|
149
|
+
|
|
150
|
+
| Code | When | HTTP status |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `auth.required` | `auth.login === true` and `user?.id` is missing. | 401 |
|
|
153
|
+
| `auth.forbidden` | `auth.additional[]` predicate rejects, or the HTTP-transport defensive `if (!user)` fires. | 403 |
|
|
154
|
+
| `auth.invalidCondition` | `auth.additional[].key` is not present on the session shape. Setup error. | 500 |
|
|
155
|
+
| Custom `errorCode` from `validateRequest` | Custom predicate (none ship by default) overrides the fallback. | Custom `httpStatus`, default 403. |
|
|
156
|
+
|
|
157
|
+
All four codes go through `normalizeErrorResponse` so the localized normalizer can render a translated message (see [`./error-handling.md`](./error-handling.md)).
|
|
158
|
+
|
|
159
|
+
## Ordering: why auth runs before rate limit
|
|
160
|
+
|
|
161
|
+
Auth is step 6 of 13. Rate limit is step 7. The comment in `handleApiRequest.ts` puts it bluntly:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
//? Auth → rate-limit → validate → execute → respond.
|
|
165
|
+
//? Auth runs before validate so unauthenticated probes can't enumerate
|
|
166
|
+
//? routes or learn input shape from `inputValidation.message`.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
In practice this means:
|
|
170
|
+
|
|
171
|
+
- 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.
|
|
172
|
+
- 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).
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
## Authorization extension points
|
|
177
|
+
|
|
178
|
+
The auth gate's predicate engine is intentionally small. Two ways to add custom behavior:
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
2. **Hook-based auth via `preApiExecute`.** Register a hook that inspects `user` + `routeName` + `data` and returns a stop signal:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
registerHook('preApiExecute', async ({ routeName, user, data }) => {
|
|
186
|
+
if (routeName.startsWith('admin/') && !user?.admin) {
|
|
187
|
+
return { stop: true, errorCode: 'auth.forbidden', httpStatus: 403 };
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`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.
|
|
193
|
+
|
|
194
|
+
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(...)`.
|
|
195
|
+
|
|
196
|
+
## Hooks dispatched
|
|
197
|
+
|
|
198
|
+
The auth gate itself does not dispatch hooks. Adjacent hooks that observe auth state:
|
|
199
|
+
|
|
200
|
+
| Hook | When | Payload |
|
|
201
|
+
| --- | --- | --- |
|
|
202
|
+
| `preSessionRefresh` / `postSessionRefresh` | Inside `getSession`, on every authenticated request | `{ token, oldTtl, newTtl }`. `preSessionRefresh` can stop the TTL extend. |
|
|
203
|
+
| `preApiExecute` | After the `AuthProps` gate, before `main(...)` | `{ routeName, data, user }`. Can stop with `{ errorCode, httpStatus }` for custom authorization. |
|
|
204
|
+
|
|
205
|
+
See [`./api-request-lifecycle.md`](./api-request-lifecycle.md) for the full hook order.
|
|
206
|
+
|
|
207
|
+
## Config keys
|
|
208
|
+
|
|
209
|
+
This package does not read any auth-specific config. `@luckystack/login` owns session policy:
|
|
210
|
+
|
|
211
|
+
- `session.expiryDays` (TTL for `getSession` sliding-extend).
|
|
212
|
+
- `session.perUser`, `session.maxConcurrentPerUser`, `session.onConflict` (single-session enforcement on new login).
|
|
213
|
+
- `auth.passwordPolicy.*` (credentials register / change paths).
|
|
214
|
+
- `auth.forgotPassword` (framework vs custom vs disabled).
|
|
215
|
+
|
|
216
|
+
Read those in `@luckystack/login/CLAUDE.md`.
|
|
217
|
+
|
|
218
|
+
## Edge cases
|
|
219
|
+
|
|
220
|
+
- **`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.
|
|
221
|
+
- **`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.
|
|
222
|
+
- **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.
|
|
223
|
+
- **`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.
|
|
224
|
+
- **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`.
|
|
225
|
+
|
|
226
|
+
## Related
|
|
227
|
+
|
|
228
|
+
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
229
|
+
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
230
|
+
- Session management: `@luckystack/login/docs/session-management.md`
|
|
231
|
+
- OAuth providers: `@luckystack/login/docs/oauth-providers.md`
|
|
232
|
+
- Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md), [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Error Handling
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@luckystack/api` produces a strict, two-shape response envelope. Every code path through the handler — malformed input, auth failure, rate-limit rejection, handler throw, handler return — ends in either `{ status: 'success', ... }` or `{ status: 'error', errorCode, errorParams?, httpStatus, message }`. The transformation from raw failure to wire envelope flows through three layers:
|
|
8
|
+
|
|
9
|
+
1. **`tryCatch`** wraps the handler's `main(...)` call. Errors are captured (auto-forwarded to Sentry / any registered tracker with context), and the tuple `[error, result]` is returned to the pipeline. Raw exceptions never reach the client.
|
|
10
|
+
2. **`buildApiResponseEnvelope`** (socket) / **`buildNetworkError`** (HTTP) maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse` and routing the error fields through `normalizeErrorResponse`.
|
|
11
|
+
3. **`normalizeErrorResponse`** renders the localized `message` field from the `errorCode` + `errorParams`, using the project-registered translate function. Locale is extracted in priority order: `x-language` header → `accept-language` header → `user.language`.
|
|
12
|
+
|
|
13
|
+
This means: a handler can return `{ status: 'error', errorCode: 'organization.notFound', errorParams: [{ key: 'id', value: orgId }] }` and the framework renders `Organization "abc123" not found` (or its translation) automatically. Business-logic errors and framework-emitted errors share the same envelope shape.
|
|
14
|
+
|
|
15
|
+
Logging is uniform: every handler exception is logged via `getLogger().error(...)` with the route name and transport in the context tag, plus a stack via `tryCatch`'s capture. Dev-only warn logs are gated by `logging.devLogs`.
|
|
16
|
+
|
|
17
|
+
## API Reference
|
|
18
|
+
|
|
19
|
+
### `executeApiHandler` (internal, socket transport)
|
|
20
|
+
|
|
21
|
+
**Signature**:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const executeApiHandler = async (params: {
|
|
25
|
+
apiEntry: RuntimeApiEntry;
|
|
26
|
+
normalizedData: Record<string, unknown>;
|
|
27
|
+
user: SessionLayout | null;
|
|
28
|
+
functionsObject: Record<string, unknown>;
|
|
29
|
+
resolvedName: string;
|
|
30
|
+
name: string;
|
|
31
|
+
emitStream: (payload?: ApiStreamPayload) => void;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
error: Error | null;
|
|
34
|
+
result: RuntimeApiResponse | undefined;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
}>;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Behavior**:
|
|
40
|
+
|
|
41
|
+
- Starts a Sentry span via `startSpan(name, 'api.request')`. The HTTP variant uses `'api.request.http'`.
|
|
42
|
+
- Calls `apiEntry.main({ data, user, functions, stream })` inside `tryCatch(..., undefined, { handler, api, userId, transport })`.
|
|
43
|
+
- The third argument to `tryCatch` is the auto-capture context — recorded on the registered error tracker alongside the exception.
|
|
44
|
+
- `span.end()` is called unconditionally after `main` resolves or rejects.
|
|
45
|
+
- Returns `{ error, result, durationMs }`. Caller decides what to do with each.
|
|
46
|
+
|
|
47
|
+
The HTTP transport has no extracted helper — the same flow is inlined.
|
|
48
|
+
|
|
49
|
+
### `tryCatch` (re-exported from `@luckystack/core`)
|
|
50
|
+
|
|
51
|
+
**Signature** (paraphrased):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
async function tryCatch<T>(
|
|
55
|
+
fn: () => Promise<T>,
|
|
56
|
+
fallback?: T,
|
|
57
|
+
captureContext?: Record<string, unknown>,
|
|
58
|
+
): Promise<[Error | null, T | null]>;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Behavior**: invokes `fn`; on throw, captures the error via `captureExceptionAcrossTrackers(error, captureContext)`, and returns `[error, null]`. On success, returns `[null, result]`. Used as the universal error boundary across the framework — never use raw `try / catch`.
|
|
62
|
+
|
|
63
|
+
### `buildApiResponseEnvelope` (internal, socket transport)
|
|
64
|
+
|
|
65
|
+
**Signature**:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const buildApiResponseEnvelope = (params: {
|
|
69
|
+
resolvedName: string;
|
|
70
|
+
error: Error | null;
|
|
71
|
+
result: RuntimeApiResponse | undefined;
|
|
72
|
+
preferredLocale: string | undefined;
|
|
73
|
+
user: SessionLayout | null;
|
|
74
|
+
}): { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown };
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Behavior**:
|
|
78
|
+
|
|
79
|
+
| Condition | Returns |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `error` is non-null | Localized error envelope with `errorCode: 'api.internalServerError'`, `httpStatus: 500`. |
|
|
82
|
+
| `result` is `null` or `undefined` | Localized error envelope with `errorCode: 'api.emptyResponse'`, `httpStatus: 500`. |
|
|
83
|
+
| `result.status` is neither `'success'` nor `'error'` | Localized error envelope with `errorCode: 'api.invalidResponseStatus'`, `httpStatus: 500`. |
|
|
84
|
+
| `result.status === 'error'` | Pass through `normalizeErrorResponse({ response: result, ..., fallbackHttpStatus: defaultHttpStatusForResponse(...) })`. |
|
|
85
|
+
| `result.status === 'success'` | Spread `result`, set `httpStatus` via `defaultHttpStatusForResponse({ status: 'success', explicitHttpStatus: result.httpStatus })`. |
|
|
86
|
+
|
|
87
|
+
All four error branches and the success branch produce a fully-formed envelope ready to emit.
|
|
88
|
+
|
|
89
|
+
### `buildNetworkError` (internal closure in `handleHttpApiRequest`)
|
|
90
|
+
|
|
91
|
+
The HTTP-side equivalent. Same shape as the socket's localized-error path, but the return is typed `ApiNetworkResponse` (which has a non-optional `httpStatus: number`). Used at every HTTP early-return.
|
|
92
|
+
|
|
93
|
+
### `normalizeErrorResponse` (re-exported from `@luckystack/core`)
|
|
94
|
+
|
|
95
|
+
**Signature**:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
function normalizeErrorResponse(params: {
|
|
99
|
+
response: { status: 'error'; errorCode?: string; errorParams?: [...]; httpStatus?: number; message?: string };
|
|
100
|
+
preferredLocale?: string | null;
|
|
101
|
+
userLanguage?: string | null;
|
|
102
|
+
fallbackHttpStatus?: number;
|
|
103
|
+
}): {
|
|
104
|
+
status: 'error';
|
|
105
|
+
httpStatus: number;
|
|
106
|
+
errorCode: string;
|
|
107
|
+
errorParams?: [...];
|
|
108
|
+
message: string;
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Behavior**:
|
|
113
|
+
|
|
114
|
+
- Dispatches the `preErrorNormalize` sync hook so consumers can remap `errorCode` (e.g. rewrite `auth.required` → `session.expired`).
|
|
115
|
+
- Resolves the locale: `preferredLocale ?? userLanguage ?? defaultLanguage`.
|
|
116
|
+
- Calls the registered localized normalizer (set by `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts`) with the `errorCode` + `errorParams` to render `message`.
|
|
117
|
+
- Falls back to the raw `errorCode` string when no normalizer is registered or the key is missing from the locale.
|
|
118
|
+
- Sets `httpStatus` to `response.httpStatus ?? fallbackHttpStatus ?? 500`.
|
|
119
|
+
- Dispatches the `postErrorNormalize` sync hook so consumers can rewrite the final shape (e.g. strip stack details, add a correlation ID).
|
|
120
|
+
- Returns the localized envelope.
|
|
121
|
+
|
|
122
|
+
### `defaultHttpStatusForResponse` (re-exported from `@luckystack/core`)
|
|
123
|
+
|
|
124
|
+
**Signature**:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
function defaultHttpStatusForResponse(params: {
|
|
128
|
+
status: 'success' | 'error';
|
|
129
|
+
explicitHttpStatus?: number;
|
|
130
|
+
}): number;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Behavior**: returns `explicitHttpStatus` when present; otherwise `200` for `'success'` and `500` for `'error'`. The handler's per-response `httpStatus` (set inside `main`) always wins.
|
|
134
|
+
|
|
135
|
+
### `setSentryUser` + `startSpan` (from `@luckystack/error-tracking`)
|
|
136
|
+
|
|
137
|
+
Called early in each handler:
|
|
138
|
+
|
|
139
|
+
- `setSentryUser({ id, email })` attributes every subsequent capture (within this request's call stack) to the user. Cleared with `setSentryUser(null)` between requests by virtue of being called per-request.
|
|
140
|
+
- `startSpan(name, op)` opens a tracing span. The returned object exposes `.end()` (sometimes — depends on adapter). Both transports defensively call `span?.end?.()` to support adapters that don't implement spans.
|
|
141
|
+
|
|
142
|
+
## Framework-emitted error codes
|
|
143
|
+
|
|
144
|
+
| Code | Origin | HTTP status | Notes |
|
|
145
|
+
| --- | --- | --- | --- |
|
|
146
|
+
| `api.invalidRequest` | `validateApiMessage` (socket) | 400 | Malformed socket envelope. |
|
|
147
|
+
| `api.invalidName` | HTTP entry validation | 400 | `name` missing or not a string. |
|
|
148
|
+
| `api.invalidDataObject` | HTTP entry validation | 400 | `data` missing, null, or not an object. |
|
|
149
|
+
| `routing.invalidServiceRouteName` | `parseTransportRouteName` | 400 | Route name does not match `api/<page>/<name>/v<N>`. `errorParams[0].value` echoes the original name. |
|
|
150
|
+
| `api.notFound` | runtime map lookup | 404 | Route not registered. `errorParams[0].value` echoes the name. |
|
|
151
|
+
| `auth.required` | `checkApiAuth` | 401 | `auth.login: true` and no session. |
|
|
152
|
+
| `auth.forbidden` | `validateRequest` predicate | 403 | Predicate rejected. May carry custom `errorCode` from a future custom validator. |
|
|
153
|
+
| `auth.invalidCondition` | `validateRequest` | 500 | `auth.additional[].key` is not on the session shape. Setup error. |
|
|
154
|
+
| `api.rateLimitExceeded` | `applyApiRateLimits` | 429 | `errorParams[0]` is `{ key: 'seconds', value: resetIn }`. |
|
|
155
|
+
| `api.methodNotAllowed` | HTTP method check | 405 | `errorParams[0]` is `{ key: 'method', value: expectedMethod }`. |
|
|
156
|
+
| `api.invalidInputType` | `validateInputByType` | 400 | `errorParams[0]` is `{ key: 'message', value: <validator message> }`. |
|
|
157
|
+
| `api.internalServerError` | `tryCatch` caught a throw | 500 | The original exception is on the registered tracker; not in the wire envelope. |
|
|
158
|
+
| `api.emptyResponse` | `buildApiResponseEnvelope` | 500 | Handler returned `null` or `undefined`. |
|
|
159
|
+
| `api.invalidResponseStatus` | `buildApiResponseEnvelope` / `isRuntimeApiResult` | 500 | Handler returned an object whose `status` is not `'success'` / `'error'`. |
|
|
160
|
+
|
|
161
|
+
Consumer-emitted error codes (returned from `main(...)`) flow through `normalizeErrorResponse` unchanged. Consumers should register translations for the framework codes above plus their own.
|
|
162
|
+
|
|
163
|
+
## Stop signals
|
|
164
|
+
|
|
165
|
+
`preApiExecute` and `preApiRespond` hook handlers may return a `HookStopSignal`:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
return { stop: true, errorCode: 'organization.disabled', httpStatus: 403 };
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When the dispatcher sees a stop signal:
|
|
172
|
+
|
|
173
|
+
- `preApiExecute`: the handler short-circuits before `main(...)` runs. The envelope is built as `normalizeErrorResponse({ response: { errorCode }, fallbackHttpStatus: signal.httpStatus ?? 403 })`.
|
|
174
|
+
- `preApiRespond`: the already-built envelope is discarded; a fresh localized envelope is built from the signal. `transformApiResponse` and `postApiRespond` still fire on the replacement.
|
|
175
|
+
|
|
176
|
+
Other lifecycle hooks (`preApiValidate`, `postApiValidate`, `postApiExecute`, `transformApiResponse`, `postApiRespond`) do not honor stop signals — they are pre-validation observation, post-execution observation, or pure mutation slots.
|
|
177
|
+
|
|
178
|
+
## Locale extraction
|
|
179
|
+
|
|
180
|
+
Order of precedence for the `preferredLocale` argument that flows into `normalizeErrorResponse`:
|
|
181
|
+
|
|
182
|
+
1. **Socket transport**: `extractLanguageFromHeader(socket.handshake.headers['x-language'])` → `extractLanguageFromHeader(socket.handshake.headers['accept-language'])`.
|
|
183
|
+
2. **HTTP transport**: `extractLanguageFromHeader(params.xLanguageHeader)` → `extractLanguageFromHeader(params.acceptLanguageHeader)`. The HTTP listener in `@luckystack/server` is responsible for passing these headers in.
|
|
184
|
+
3. **Fallback**: `user.language` (from the session) when both headers are absent.
|
|
185
|
+
4. **Final fallback**: `defaultLanguage` from `projectConfig` when there is no session.
|
|
186
|
+
|
|
187
|
+
`extractLanguageFromHeader` returns the first valid locale token (`en`, `nl`, `de`, `fr`) from a possibly multi-value header, or `undefined`. The localized normalizer also implements its own fallback chain.
|
|
188
|
+
|
|
189
|
+
## Logging
|
|
190
|
+
|
|
191
|
+
Every error path logs at the appropriate level:
|
|
192
|
+
|
|
193
|
+
| Level | When |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `getLogger().debug(...)` | Successful route completion, when `logging.devLogs` is on. |
|
|
196
|
+
| `getLogger().warn(...)` | Auth failures, rate-limit exceeded, method mismatch, validation failures, empty returns, malformed envelopes. All gated by `logging.devLogs`. |
|
|
197
|
+
| `getLogger().error(...)` | Handler exceptions caught by `tryCatch`. Always logged regardless of `devLogs`. Carries `{ route, transport }` context. |
|
|
198
|
+
|
|
199
|
+
The error tracker (registered via `@luckystack/error-tracking`) receives every `tryCatch` capture automatically with the context tags (`handler`, `api`, `userId`, `transport`).
|
|
200
|
+
|
|
201
|
+
## What is NOT handled here
|
|
202
|
+
|
|
203
|
+
- **Business-logic errors inside `main(...)`.** Routes return `{ status: 'error', errorCode: '...' }` directly. The envelope normalizer renders them — no thrown exception, no Sentry capture. This is by design: business outcomes (validation failures in domain code, "user already exists") are not "errors" in the tracking sense.
|
|
204
|
+
- **Localization of `errorCode` strings.** That is the consumer's responsibility — `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts` provides the translate function backed by JSON locale files in `src/_locales/`.
|
|
205
|
+
- **Frontend error rendering.** The client's `apiRequest` returns the normalized envelope. UI code consumes `result.errorCode` + `result.message` and decides how to render (toast via `notify`, inline form error, redirect).
|
|
206
|
+
- **Hook handler errors.** Failures inside a `dispatchHook` handler are isolated by the hook bus — they log via `getLogger()` + the tracker but do not affect the main pipeline.
|
|
207
|
+
|
|
208
|
+
## Hooks dispatched
|
|
209
|
+
|
|
210
|
+
The error-handling layer fires:
|
|
211
|
+
|
|
212
|
+
| Hook | Stoppable | Payload |
|
|
213
|
+
| --- | --- | --- |
|
|
214
|
+
| `preApiRespond` | yes (becomes localized error) | `{ routeName, user, response }` — last chance to mutate or replace the envelope. |
|
|
215
|
+
| `transformApiResponse` | no (mutate `response`) | `{ routeName, user, response }` — pure mutation slot. |
|
|
216
|
+
| `postApiRespond` | no | `{ routeName, user, response }` — observation after emit. |
|
|
217
|
+
| `preErrorNormalize` | no (sync; mutate `response`) | `{ response: ErrorResponseInput }` — fires inside `normalizeErrorResponse`. |
|
|
218
|
+
| `postErrorNormalize` | no (sync; mutate `normalized`) | `{ normalized: NormalizedErrorResponse }` — fires before `normalizeErrorResponse` returns. |
|
|
219
|
+
|
|
220
|
+
See [`./api-request-lifecycle.md`](./api-request-lifecycle.md) for the full hook order and `@luckystack/core/docs/hooks.md` for hook bus semantics.
|
|
221
|
+
|
|
222
|
+
## Config keys
|
|
223
|
+
|
|
224
|
+
| Key | Effect |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| `logging.devLogs` | Gates verbose per-request `debug` / `warn` logs. Handler-exception `error` logs always emit. |
|
|
227
|
+
| `defaultLanguage` | Final fallback locale for `normalizeErrorResponse`. |
|
|
228
|
+
| (registered via DI) | The localized normalizer is registered by the project's boot code. Without one, error responses still carry `errorCode` but `message` falls back to the raw key. |
|
|
229
|
+
|
|
230
|
+
## Edge cases
|
|
231
|
+
|
|
232
|
+
- **Handler returns `{ status: 'error' }` with no `errorCode`.** `normalizeErrorResponse` treats it as having a missing key — the rendered `message` falls back to the raw value (an empty string or the key literal). Always include `errorCode`.
|
|
233
|
+
- **Handler returns `httpStatus: 0` or a non-number.** `defaultHttpStatusForResponse` only treats `typeof === 'number'` as explicit; non-numbers fall back to 200/500 per the success/error branch.
|
|
234
|
+
- **Stop signal with no `httpStatus`.** Falls back to `403` for both `preApiExecute` and `preApiRespond` stops.
|
|
235
|
+
- **`tryCatch` caught a non-Error throw.** The framework wraps non-Error throws into an Error before capture; the client always gets `api.internalServerError`. The tracker receives the wrapped Error with the original `cause`.
|
|
236
|
+
- **`preApiRespond` stop on an already-error envelope.** The original error envelope is replaced by the stop signal's error envelope. Use this pattern to remap error codes (`auth.required` → `session.expired`) — but prefer the sync `preErrorNormalize` hook for code remapping since it runs in a single place.
|
|
237
|
+
- **HTTP request with no `requesterIp` and no token.** The per-route bucket keys on `ip:anonymous:...` (per route) and `ip:unknown:...` (global). All such requests share these buckets. Front your server with a load balancer that always sets `X-Forwarded-For`.
|
|
238
|
+
|
|
239
|
+
## Related
|
|
240
|
+
|
|
241
|
+
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
242
|
+
- Auth flow: [`./auth-flow.md`](./auth-flow.md)
|
|
243
|
+
- Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
|
|
244
|
+
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
245
|
+
- Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Error Code Rules", "Framework-emitted error codes", "Response Contract")
|
|
246
|
+
- Hooks: `@luckystack/core/docs/hooks.md`
|
|
247
|
+
- Error-tracker registry: `@luckystack/core/docs/error-tracker-registry.md`
|