@luckystack/api 0.6.7 → 0.7.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/CLAUDE.md +113 -113
- package/LICENSE +21 -21
- package/README.md +106 -106
- package/dist/index.js.map +1 -1
- package/docs/api-request-lifecycle.md +280 -280
- package/docs/auth-flow.md +232 -232
- package/docs/error-handling.md +246 -246
- package/docs/generated-types.md +264 -264
- package/docs/rate-limiting.md +188 -188
- package/docs/routing-conventions.md +164 -164
- package/package.json +3 -3
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) | `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. |
|
|
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 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. |
|
|
20
|
-
|
|
21
|
-
## API Reference
|
|
22
|
-
|
|
23
|
-
### `readSession(token)` (from `@luckystack/core`)
|
|
24
|
-
|
|
25
|
-
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).
|
|
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
|
-
### Error-tracker identity propagation
|
|
30
|
-
|
|
31
|
-
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.
|
|
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: 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.
|
|
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) | `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. |
|
|
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 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. |
|
|
20
|
+
|
|
21
|
+
## API Reference
|
|
22
|
+
|
|
23
|
+
### `readSession(token)` (from `@luckystack/core`)
|
|
24
|
+
|
|
25
|
+
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).
|
|
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
|
+
### Error-tracker identity propagation
|
|
30
|
+
|
|
31
|
+
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.
|
|
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: 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.
|
|
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)
|