@luckystack/api 0.1.9 → 0.2.1

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/docs/auth-flow.md CHANGED
@@ -1,232 +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)
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)
@@ -153,10 +153,10 @@ Called early in each handler:
153
153
  | `auth.invalidCondition` | `validateRequest` | 500 | `auth.additional[].key` is not on the session shape. Setup error. |
154
154
  | `api.rateLimitExceeded` | `applyApiRateLimits` | 429 | `errorParams[0]` is `{ key: 'seconds', value: resetIn }`. |
155
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> }`. |
156
+ | `api.invalidInputType` | `validateInputByType` | 400 | Generic code only — the raw validator message is NOT echoed to the client (it would leak the input schema to unauthenticated callers). The detailed message is routed to the `postApiValidate` hook + dev logs. |
157
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'`. |
158
+ | `api.emptyResponse` | `normalizeApiResponse` | 500 | Handler returned `null` or `undefined`. |
159
+ | `api.invalidResponseStatus` | `normalizeApiResponse` | 500 | Handler returned an object whose `status` is not `'success'` / `'error'`. |
160
160
 
161
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
162
 
@@ -121,7 +121,7 @@ const inputValidation = await validateInputByType({
121
121
  | `rootKey` | `string` | Label used in error messages (`data` for API, `clientInput` for sync). |
122
122
  | `filePath` | `string \| undefined` | Source file path. The dev-mode resolver lazy-loads `@luckystack/devkit` to re-walk imports / re-exports. |
123
123
 
124
- **Return shape**: `{ status: 'success' } | { status: 'error'; message: string }`. On error the handler responds with `api.invalidInputType` + `400`, surfacing the validator's `message` as `errorParams[0].value`.
124
+ **Return shape**: `{ status: 'success' } | { status: 'error'; message: string }`. On error the handler responds with the generic `api.invalidInputType` + `400`. The validator's `message` is NOT surfaced to the client (echoing it would let unauthenticated callers enumerate the input schema); it is routed to the `postApiValidate` hook payload (`validation.message`) and the dev logs instead.
125
125
 
126
126
  `validateInputByType` lazy-loads `@luckystack/devkit`'s deep resolver in development so types defined across files (re-exported aliases, utility wrappers like `Partial`/`Pick`/`Omit`/`Record`) expand correctly. In production it relies on the resolver's last-known cache — regenerate before shipping.
127
127
 
@@ -56,7 +56,7 @@ Both transports execute exactly:
56
56
  2. If `effectiveApiLimit !== false && effectiveApiLimit > 0`:
57
57
  - Build the per-route key.
58
58
  - `await checkRateLimit({ key, limit, windowMs })`.
59
- - On `!allowed`, dispatch `rateLimitExceeded` with `scope: token ? 'user' : 'route'`, respond `api.rateLimitExceeded` + `429`, abort.
59
+ - On `!allowed`, dispatch `rateLimitExceeded` with `scope: token ? 'user' : 'ip'` (the bucket is user-keyed only when a token is present; an anonymous per-route bucket is IP-keyed, so it reports `ip` — with `route` still set to distinguish it from the global `:api:all` IP bucket below), respond `api.rateLimitExceeded` + `429`, abort.
60
60
  3. Read `defaultIpLimit = rateLimiting.defaultIpLimit`.
61
61
  4. If `defaultIpLimit !== false && defaultIpLimit > 0`:
62
62
  - Build the global IP key.
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@luckystack/api",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
- "access": "public"
6
+ "access": "public",
7
+ "provenance": true
7
8
  },
8
9
  "type": "module",
9
10
  "description": "Type-safe API request handlers for LuckyStack. WebSocket-first via socket.io, with HTTP fallback. File-based routing, generated route map, integrated rate limiting, validation, hooks, and error-tracking tracing.",
@@ -55,9 +56,8 @@
55
56
  "test": "vitest run"
56
57
  },
57
58
  "dependencies": {
58
- "@luckystack/core": "^0.1.9",
59
- "@luckystack/login": "^0.1.9",
60
- "@luckystack/error-tracking": "^0.1.9"
59
+ "@luckystack/core": "^0.2.1",
60
+ "@luckystack/error-tracking": "^0.2.1"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@prisma/client": "^6.19.0",