@luckystack/server 0.6.7 → 0.7.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.
@@ -1,318 +1,318 @@
1
- # HTTP Routes
2
-
3
- > Deep specs. Bron: `packages/server/src/httpHandler.ts`, `packages/server/src/httpRoutes/*.ts`, `packages/server/src/customRoutesRegistry.ts`. Bijgewerkt: 2026-05-20.
4
-
5
- ## Overview
6
-
7
- `@luckystack/server` ships every HTTP route the framework needs: liveness / readiness / health probes, CSRF token issue + middleware, the destructive test-reset endpoint, favicon, uploads, OAuth (`/auth/api` + `/auth/callback`), the dispatcher to `/api/*` and `/sync/*`, custom routes, and a static-file SPA fallback. The orchestrator in `httpHandler.ts` runs them as two ordered tables (PRE_PARAMS and POST_PARAMS). Each handler matches its own route path and returns `boolean`. First handler to return `true` (or end the response) terminates dispatch.
8
-
9
- Pre-params handlers (no body parse) run first so probes, favicon, and CSRF stay cheap:
10
-
11
- 1. `handleCsrfRoute` — `/auth/csrf`
12
- 2. `handleFaviconRoute` — `/favicon.ico`
13
- 3. `handleLivezRoute` — `projectConfig.http.liveEndpoint` (default `/livez`)
14
- 4. `handleReadyzRoute` — `projectConfig.http.readyEndpoint` (default `/readyz`)
15
- 5. `handleHealthRoute` — `projectConfig.http.healthEndpoint` (default `/_health`)
16
- 6. `handleTestResetRoute` — `projectConfig.http.testResetEndpoint` (default `/_test/reset`)
17
-
18
- Post-params handlers (after body parsing) run next:
19
-
20
- 1. `handleUploadsRoute` — `/uploads/*`
21
- 2. `handleAuthEmailCodeRoute` — `/auth/api/email-code/request|verify` (ADR 0024; BEFORE the `/auth/api/*` catch-all)
22
- 3. `handleAuthTwoFactorRoute` — `/auth/api/2fa` + `/auth/api/2fa/*` (ADR 0024; BEFORE the `/auth/api/*` catch-all)
23
- 4. `handleAuthApiRoute` — `/auth/api/*`
24
- 5. `handleAuthCallbackRoute` — `/auth/callback/*`
25
- 6. `handleApiRoute` — `/api/*` (delegates to `@luckystack/api`)
26
- 7. `handleSyncRoute` — `/sync/*` (delegates to `@luckystack/sync`)
27
- 8. `handleCustomRoutes` — every handler from `registerCustomRoute(...)` + `options.customRoutes`
28
- 9. `handleStaticAndSpaFallback` — `/assets/*`, known extensions, SPA `index.html`
29
-
30
- A custom-route registry sits in front of the static fallback. Both the legacy `customRoutes` option on `CreateLuckyStackServerOptions` and the global `registerCustomRoute(...)` registry are honored. Errors in custom handlers are caught and reported as `500 server.customRouteFailed`; the request loop is never crashed by a misbehaving handler.
31
-
32
- ## Framework route reference
33
-
34
- | Route | Method(s) | Handler | Response (success) | Response (failure) |
35
- | --- | --- | --- | --- | --- |
36
- | `/auth/csrf` | GET | `handleCsrfRoute` | `200 { status: 'success', csrfToken }` | `401 auth.unauthenticated` |
37
- | `/favicon.ico` | GET | `handleFaviconRoute` | Whatever `options.serveFavicon(res)` writes | `404` empty when no handler supplied |
38
- | `/livez` | GET | `handleLivezRoute` | `200 { status: 'live' }` | Always 200 when reachable |
39
- | `/readyz` | GET | `handleReadyzRoute` | `200 { status: 'ready', checks: {...} }` | `503 { status: 'not-ready', checks: {...} }` |
40
- | `/_health` | GET | `handleHealthRoute` | `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` | `503 { status: 'degraded', ... }` when boot UUID is missing |
41
- | `/_test/reset` | POST | `handleTestResetRoute` | `200 { status: 'success', cleared: string[] }` | `404 notFound` or `403 auth.forbidden` — see `security-defaults.md` |
42
- | `/uploads/*` | GET | `handleUploadsRoute` | Avatar bytes via `@luckystack/core` `serveAvatar` | `404` from `serveAvatar` when missing |
43
- | `/auth/api/email-code/request` | POST | `handleAuthEmailCodeRoute` | `200 { status: true }` (anti-enumeration: also for unknown addresses) | `200 { status:false, reason }`, `429` per-IP |
44
- | `/auth/api/email-code/verify` | POST | `handleAuthEmailCodeRoute` | Login envelope + session transport, or 2FA-challenge envelope | `200 { status:false, reason }`, `429` per-IP |
45
- | `/auth/api/2fa` | POST | `handleAuthTwoFactorRoute` | Login envelope + session transport (completes the challenge) | `200 { status:false, reason }`, `429` per-IP |
46
- | `/auth/api/2fa/email-code` | POST | `handleAuthTwoFactorRoute` | `200 { status: true }` (fallback code sent) | `200 { status:false, reason }`, `429` per-IP |
47
- | `/auth/api/2fa/setup\|enable\|disable\|recovery-codes` | POST (authed) | `handleAuthTwoFactorRoute` | `200 { status:true, secret/otpauthUri \| recoveryCodes[] }` | `401 api.unauthorized` without session; `200 { status:false, reason }` |
48
- | `/auth/api/*` | POST | `handleAuthApiRoute` | OAuth redirect (`302`) or credentials login envelope (may carry `requiresTwoFactor` + `challengeToken`) | Rate-limited envelope, `200` envelope with `status:false, reason` |
49
- | `/auth/callback/*` | GET | `handleAuthCallbackRoute` | `302` to `redirectUrl` with session cookie or token | `401 'Login failed'` plain text |
50
- | `/api/*` | GET / POST / PUT / DELETE | `handleApiRoute` | JSON envelope from `@luckystack/api` `handleHttpApiRequest`, or SSE stream | `400 api.invalidName`, `500 api.invalidRequestFormat` |
51
- | `/sync/*` | POST | `handleSyncRoute` | JSON envelope from `@luckystack/sync` `handleHttpSyncRequest`, or SSE stream | `405 sync.methodNotAllowed`, `400 sync.invalidName`, `500 sync.invalidRequestFormat` |
52
- | `/assets/*`, `*.{png,jpg,jpeg,gif,svg,html,css,js}` | GET | `handleStaticAndSpaFallback` | Whatever `options.serveFile` writes (URL temporarily rewritten) | `404` when no `serveFile` is wired |
53
- | Any other extension-less path | GET | `handleStaticAndSpaFallback` | SPA fallback (rewrite URL to `/`, call `serveFile`) | `404` when no `serveFile` |
54
-
55
- > The CSRF header name clients must send (for the `/auth/csrf` route above) is configurable: it comes from `getCsrfConfig().headerName` (default `x-csrf-token`), renamable via `registerCsrfConfig({ headerName })` from `@luckystack/core`.
56
-
57
- ## API Reference — handlers
58
-
59
- All handlers conform to:
60
-
61
- ```typescript
62
- type HttpRouteHandler = (ctx: HttpRouteContext) => Promise<boolean>;
63
-
64
- interface HttpRouteContext {
65
- req: IncomingMessage;
66
- res: ServerResponse;
67
- options: CreateLuckyStackServerOptions;
68
- routePath: string;
69
- queryString: string | undefined;
70
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
71
- token: string | null;
72
- requestId: string;
73
- sessionCookieOptions: string;
74
- params: object;
75
- }
76
- ```
77
-
78
- A handler returns `true` (or simply ends `res`) to short-circuit dispatch; returning `false` falls through to the next handler.
79
-
80
- ### `handleLivezRoute(ctx)`
81
-
82
- **Behavior:**
83
-
84
- - Matches when `routePath === projectConfig.http.liveEndpoint` (default `/livez`).
85
- - Always emits `200 application/json { status: 'live' }`.
86
-
87
- **Edge cases:** no downstream dependencies; succeeds as long as the Node event loop is responsive.
88
-
89
- ### `handleReadyzRoute(ctx)`
90
-
91
- **Behavior:**
92
-
93
- - Matches when `routePath === projectConfig.http.readyEndpoint` (default `/readyz`).
94
- - Sequentially checks: `readBootUuid()` (Redis), `redis.ping()`, and a database check (ADR 0020):
95
- 1. a consumer-registered `registerDbHealthCheck(...)` probe (from `@luckystack/core`) wins;
96
- 2. otherwise the built-in Prisma probe runs when Prisma is part of the install (`isPrismaClientRegistered()` or a resolvable `@prisma/client`) — provider-agnostic: tries `$queryRaw\`SELECT 1\`` first, then `$runCommandRaw({ ping: 1 })` (detecting the active provider via private fields drifts between Prisma majors, so it probes by capability);
97
- 3. otherwise the check reports `'skipped'` — a deliberately DB-less project (`orm: 'none'`) can still go ready.
98
- - Returns `200 { status: 'ready', checks }` when boot UUID + Redis pass and the database check is not `false`; `503 { status: 'not-ready', ... }` otherwise. `checks.database` carries the tri-state (`true | false | 'skipped'`); `checks.prisma` is kept for backward compatibility (`true` when the database check passed or was skipped).
99
-
100
- **Edge cases:**
101
-
102
- - `redis.ping()` errors are swallowed and surface as `redis: false`.
103
- - A Prisma client with neither raw method available results in `prismaOk = false`.
104
-
105
- ### `handleHealthRoute(ctx)`
106
-
107
- **Behavior:**
108
-
109
- - Matches when `routePath === projectConfig.http.healthEndpoint` (default `/_health`).
110
- - Reads `bootUuid`, `resolveEnvKey()`, and `computeSynchronizedEnvHashes()` from `@luckystack/core`.
111
- - Returns `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` when boot UUID is present, otherwise `503 { status: 'degraded', ... }`.
112
- - Consumed by `@luckystack/router` to verify shared-Redis topology after a rolling restart.
113
-
114
- ### `handleCsrfRoute(ctx)`
115
-
116
- **Behavior:**
117
-
118
- - Matches `routePath === '/auth/csrf'`.
119
- - Requires a session token (cookie or `Authorization`). Returns `401 auth.unauthenticated` when missing or when `getSession(token)` does not resolve a valid session.
120
- - On success returns `200 { status: 'success', csrfToken }`.
121
-
122
- **Note:** the matching middleware is `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). This route only issues tokens.
123
-
124
- ### `handleFaviconRoute(ctx)`
125
-
126
- **Behavior:**
127
-
128
- - Matches `routePath === '/favicon.ico'`.
129
- - Delegates to `options.serveFavicon(res)`. Without that option, returns an empty `404`.
130
-
131
- ### `handleUploadsRoute(ctx)`
132
-
133
- **Behavior:**
134
-
135
- - Matches every path starting with `/uploads/`.
136
- - Delegates to `serveAvatar({ routePath, res })` from `@luckystack/core`. Response (and `404` on miss) is owned by `serveAvatar`.
137
-
138
- ### `handleAuthApiRoute(ctx)`
139
-
140
- **Behavior:**
141
-
142
- - Matches `routePath` starting with `/auth/api`.
143
- - Looks up the provider via `getOAuthProviders().find((p) => p.name === routePath.split('/')[3])`.
144
- - For full OAuth providers (`isFullOAuthProvider(provider)`):
145
- - `createOAuthState(provider.name)` produces a CSRF state token; failure returns `500 login.oauthStateInitFailed`.
146
- - Builds the provider's authorization URL with `client_id`, `redirect_uri`, `scope`, `response_type=code`, `prompt=select_account`, `state`, and emits a `302`.
147
- - For credentials provider:
148
- - Applies an IP-keyed rate limit (`key: ip:<remote>:auth:credentials`) using `projectConfig.rateLimiting.defaultApiLimit` + `windowMs`. On hit, dispatches `rateLimitExceeded` hook and returns `{ status: false, reason: 'api.rateLimitExceeded', errorParams: [{ key: 'seconds', value: resetIn }] }`.
149
- - Calls `loginWithCredentials(params)`. On success: delete existing session, choose cookie-based vs header-based token via `x-session-based-token` header (overrides `projectConfig.session.basedToken`), then emit `Set-Cookie` or `X-Session-Token`.
150
- - Final envelope: `{ status, reason, session, authenticated }`.
151
-
152
- **Errors:** unknown provider -> `{ status: false, reason: 'login.providerNotFound' }`; thrown errors bubble to the outer `httpHandler` error path.
153
-
154
- ### `handleAuthCallbackRoute(ctx)`
155
-
156
- **Behavior:**
157
-
158
- - Matches `routePath` starting with `/auth/callback`.
159
- - Computes `baseLocation = projectConfig.app.publicUrl || '/'` (the public origin where users browse; the OAuth callback is handled on the backend origin but redirects the browser back here). `||` so an empty `publicUrl` falls through to `/`.
160
- - Delegates to `loginCallback(routePath, req, res, { defaultRedirectUrl })` from `@luckystack/login`.
161
- - On failure: `401 'Login failed'`.
162
- - On success: delete existing session, then redirect (`302`). With `projectConfig.session.basedToken: true` the token rides as `?token=...` in the query; otherwise it's emitted as a session cookie.
163
-
164
- ### `handleApiRoute(ctx)`
165
-
166
- **Behavior:**
167
-
168
- - Matches every path starting with `/api/`.
169
- - Detects SSE intent via `shouldUseHttpStream({ acceptHeader, queryString })`: returns true if `Accept` contains `text/event-stream` or `?<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). When true, initializes an SSE response header and tracks client disconnect via `req.on('close', ...)`.
170
- - Strips `/api/` prefix; empty name -> `400 api.invalidName` (or SSE `final` event with same payload).
171
- - Calls `handleHttpApiRequest({ name, data, token, requesterIp, xLanguageHeader, acceptLanguageHeader, method, stream? })` from `@luckystack/api`. The `stream` callback emits `event: stream` SSE frames during long-running calls.
172
- - Emits the result (`event: final` for SSE, or `JSON.stringify(result)` + `res.writeHead(result.httpStatus)` for non-SSE).
173
-
174
- **Errors:** any thrown error is logged + captured to Sentry, `apiError` hook dispatched, and the envelope `500 api.invalidRequestFormat` is returned (over SSE or JSON).
175
-
176
- ### `handleSyncRoute(ctx)`
177
-
178
- **Behavior:**
179
-
180
- - Matches every path starting with `/sync/`.
181
- - Only `POST` is allowed; other methods return `405 sync.methodNotAllowed`.
182
- - Normalizes params into `{ data, receiver, ignoreSelf?, cb? }`. Empty `receiver` is allowed (`''`).
183
- - Calls `handleHttpSyncRequest({ name: 'sync/<rest>', cb, data, receiver, ignoreSelf, token, requesterIp, xLanguageHeader, acceptLanguageHeader, stream? })` from `@luckystack/sync`.
184
- - SSE handling mirrors `handleApiRoute`.
185
-
186
- **Errors:** mirrors `handleApiRoute` — logs, Sentry, `syncError` hook, returns `500 sync.invalidRequestFormat`.
187
-
188
- ### `handleCustomRoutes(ctx)`
189
-
190
- **Behavior:**
191
-
192
- - Iterates every handler registered via `registerCustomRoute(...)` in registration order; first one to return `true` or end the response wins.
193
- - If none handled, falls back to the inline `options.customRoutes` if provided.
194
- - Each handler is wrapped in `tryCatch` — on throw it logs via `getLogger().error`, calls `captureException`, and emits `500 server.customRouteFailed` (returning `true` so dispatch stops).
195
-
196
- ### `handleStaticAndSpaFallback(ctx)`
197
-
198
- **Behavior:**
199
-
200
- - `/assets/*` paths: matched via `startsWith('/assets/')`; the (percent-decoded) routePath is rejected with `404` when it contains `..` (path-traversal guard at the framework boundary), then `req.url` is temporarily rewritten to the routePath, `options.serveFile` is called, and `req.url` restored. Without `serveFile`, returns `404 Not Found`.
201
- - Paths matching `KNOWN_STATIC_FILE_REGEX` (`/^\/(assets\/[a-zA-Z0-9_\-/]+|[a-zA-Z0-9_\-]+)\.(png|jpg|jpeg|gif|svg|html|css|js)$/`): call `serveFile` directly without URL rewrite.
202
- - Other paths that have an extension `path.extname(routePath)`: `404 Not Found` (text/plain).
203
- - Extensionless paths (SPA routes): rewrite `req.url` to `/`, call `serveFile`. Without `serveFile`, returns `404 Not Found`.
204
-
205
- **Why the rewrite around `serveFile`:** consumers (Vite, custom static middleware) read `req.url`. We swap then restore so downstream loggers / metrics see the original URL.
206
-
207
- ## Custom-route registry
208
-
209
- ```typescript
210
- export const registerCustomRoute: (handler: CustomRouteHandler) => void;
211
- export const getCustomRoutes: () => readonly CustomRouteHandler[];
212
- export const clearCustomRoutes: () => void;
213
- ```
214
-
215
- - `registerCustomRoute(handler)` — appends to an in-memory array. Called by overlay packages (`@luckystack/docs-ui` mounts `/_docs` this way).
216
- - `getCustomRoutes()` — read snapshot; iterated by `handleCustomRoutes`.
217
- - `clearCustomRoutes()` — empty the array; used by `@luckystack/test-runner`'s reset flow.
218
-
219
- The handler signature:
220
-
221
- ```typescript
222
- type CustomRouteHandler = (
223
- req: IncomingMessage,
224
- res: ServerResponse,
225
- ctx: RouteContext,
226
- ) => Promise<boolean> | boolean;
227
-
228
- interface RouteContext {
229
- routePath: string;
230
- method: string;
231
- queryString: string | undefined;
232
- token: string | null;
233
- }
234
- ```
235
-
236
- Return `true` (or end the response) when handled. Throws are caught and emit `500 server.customRouteFailed`.
237
-
238
- **Example — webhook receiver:**
239
-
240
- ```typescript
241
- import { registerCustomRoute } from '@luckystack/server';
242
-
243
- registerCustomRoute(async (req, res, ctx) => {
244
- if (ctx.routePath !== '/webhooks/stripe') return false;
245
- if (ctx.method !== 'POST') {
246
- res.writeHead(405);
247
- res.end();
248
- return true;
249
- }
250
- // verify signature, process body ...
251
- res.writeHead(200);
252
- res.end('ok');
253
- return true;
254
- });
255
- ```
256
-
257
- **Example — health alias for an external monitor:**
258
-
259
- ```typescript
260
- registerCustomRoute(async (_req, res, ctx) => {
261
- if (ctx.routePath !== '/healthz') return false;
262
- res.statusCode = 200;
263
- res.end('ok');
264
- return true;
265
- });
266
- ```
267
-
268
- ## SSE streaming
269
-
270
- `/api/*` and `/sync/*` both support Server-Sent Events when the client opts in. Helpers live in `packages/server/src/sse.ts`:
271
-
272
- | Helper | Behavior |
273
- | --- | --- |
274
- | `shouldUseHttpStream({ acceptHeader, queryString })` | Returns `true` if `Accept` includes `text/event-stream` OR the query string contains `<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). |
275
- | `initSseResponse(res)` | Writes `200` with `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`, `X-Accel-Buffering: no`. Emits `projectConfig.http.stream.connectedComment` as a keepalive comment if configured. |
276
- | `sendSseEvent({ res, event, data })` | Writes `event: <event>\ndata: <json>\n\n`. No-op once `res.writableEnded`. |
277
-
278
- Lifecycle in `handleApiRoute` / `handleSyncRoute`:
279
-
280
- 1. Decide SSE vs JSON via `shouldUseHttpStream`.
281
- 2. If SSE, call `initSseResponse(res)` and `req.on('close', () => { streamClosed = true; })`.
282
- 3. Pass a `stream` callback to `handleHttpApiRequest` / `handleHttpSyncRequest` that emits `event: stream` frames while the request is processing.
283
- 4. On completion, emit `event: final` with the result envelope and call `res.end()`.
284
- 5. On error, emit `event: error` with the `500 *.invalidRequestFormat` envelope.
285
-
286
- ## Hooks dispatched
287
-
288
- | Hook | Payload | When |
289
- | --- | --- | --- |
290
- | `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | `handleAuthApiRoute` credentials path when IP limit is exceeded. |
291
- | `apiError` | `{ route, method, requestId, error }` | `handleApiRoute` outer error path. |
292
- | `syncError` | `{ route, method, requestId, error }` | `handleSyncRoute` outer error path. |
293
- | `csrfMismatch` | `{ route, method, requestId, userId, providedToken }` | `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). |
294
- | `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Every incoming request before dispatch (see `request-pipeline.md`). |
295
-
296
- ## Config keys consumed
297
-
298
- | Source | Key | Effect |
299
- | --- | --- | --- |
300
- | config | `projectConfig.http.healthEndpoint` | Default `/_health`. |
301
- | config | `projectConfig.http.liveEndpoint` | Default `/livez`. |
302
- | config | `projectConfig.http.readyEndpoint` | Default `/readyz`. |
303
- | config | `projectConfig.http.testResetEndpoint` | Default `/_test/reset`. |
304
- | config | `projectConfig.http.stream.queryParam` / `enabledValue` / `connectedComment` | SSE opt-in query parameter + initial keepalive comment. |
305
- | config | `projectConfig.http.sessionCookieName` | Cookie name written by `/auth/api` / `/auth/callback`. |
306
- | config | `projectConfig.session.basedToken` | Cookie vs header token transport. |
307
- | config | `projectConfig.rateLimiting.defaultApiLimit` / `windowMs` | Credentials login rate limit. |
308
- | config | `projectConfig.app.publicUrl` | Public origin the OAuth callback redirects the browser back to. |
309
- | env | `TEST_RESET_TOKEN` | Consumed by `/_test/reset` — see `security-defaults.md`. |
310
-
311
- ## Related
312
-
313
- - Function INDEX: `packages/server/CLAUDE.md`
314
- - Request pipeline: `packages/server/docs/request-pipeline.md`
315
- - Security defaults: `packages/server/docs/security-defaults.md`
316
- - API delegate: `docs/ARCHITECTURE_API.md`
317
- - Sync delegate: `docs/ARCHITECTURE_SYNC.md`
318
- - README: `packages/server/README.md`
1
+ # HTTP Routes
2
+
3
+ > Deep specs. Bron: `packages/server/src/httpHandler.ts`, `packages/server/src/httpRoutes/*.ts`, `packages/server/src/customRoutesRegistry.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ `@luckystack/server` ships every HTTP route the framework needs: liveness / readiness / health probes, CSRF token issue + middleware, the destructive test-reset endpoint, favicon, uploads, OAuth (`/auth/api` + `/auth/callback`), the dispatcher to `/api/*` and `/sync/*`, custom routes, and a static-file SPA fallback. The orchestrator in `httpHandler.ts` runs them as two ordered tables (PRE_PARAMS and POST_PARAMS). Each handler matches its own route path and returns `boolean`. First handler to return `true` (or end the response) terminates dispatch.
8
+
9
+ Pre-params handlers (no body parse) run first so probes, favicon, and CSRF stay cheap:
10
+
11
+ 1. `handleCsrfRoute` — `/auth/csrf`
12
+ 2. `handleFaviconRoute` — `/favicon.ico`
13
+ 3. `handleLivezRoute` — `projectConfig.http.liveEndpoint` (default `/livez`)
14
+ 4. `handleReadyzRoute` — `projectConfig.http.readyEndpoint` (default `/readyz`)
15
+ 5. `handleHealthRoute` — `projectConfig.http.healthEndpoint` (default `/_health`)
16
+ 6. `handleTestResetRoute` — `projectConfig.http.testResetEndpoint` (default `/_test/reset`)
17
+
18
+ Post-params handlers (after body parsing) run next:
19
+
20
+ 1. `handleUploadsRoute` — `/uploads/*`
21
+ 2. `handleAuthEmailCodeRoute` — `/auth/api/email-code/request|verify` (ADR 0024; BEFORE the `/auth/api/*` catch-all)
22
+ 3. `handleAuthTwoFactorRoute` — `/auth/api/2fa` + `/auth/api/2fa/*` (ADR 0024; BEFORE the `/auth/api/*` catch-all)
23
+ 4. `handleAuthApiRoute` — `/auth/api/*`
24
+ 5. `handleAuthCallbackRoute` — `/auth/callback/*`
25
+ 6. `handleApiRoute` — `/api/*` (delegates to `@luckystack/api`)
26
+ 7. `handleSyncRoute` — `/sync/*` (delegates to `@luckystack/sync`)
27
+ 8. `handleCustomRoutes` — every handler from `registerCustomRoute(...)` + `options.customRoutes`
28
+ 9. `handleStaticAndSpaFallback` — `/assets/*`, known extensions, SPA `index.html`
29
+
30
+ A custom-route registry sits in front of the static fallback. Both the legacy `customRoutes` option on `CreateLuckyStackServerOptions` and the global `registerCustomRoute(...)` registry are honored. Errors in custom handlers are caught and reported as `500 server.customRouteFailed`; the request loop is never crashed by a misbehaving handler.
31
+
32
+ ## Framework route reference
33
+
34
+ | Route | Method(s) | Handler | Response (success) | Response (failure) |
35
+ | --- | --- | --- | --- | --- |
36
+ | `/auth/csrf` | GET | `handleCsrfRoute` | `200 { status: 'success', csrfToken }` | `401 auth.unauthenticated` |
37
+ | `/favicon.ico` | GET | `handleFaviconRoute` | Whatever `options.serveFavicon(res)` writes | `404` empty when no handler supplied |
38
+ | `/livez` | GET | `handleLivezRoute` | `200 { status: 'live' }` | Always 200 when reachable |
39
+ | `/readyz` | GET | `handleReadyzRoute` | `200 { status: 'ready', checks: {...} }` | `503 { status: 'not-ready', checks: {...} }` |
40
+ | `/_health` | GET | `handleHealthRoute` | `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` | `503 { status: 'degraded', ... }` when boot UUID is missing |
41
+ | `/_test/reset` | POST | `handleTestResetRoute` | `200 { status: 'success', cleared: string[] }` | `404 notFound` or `403 auth.forbidden` — see `security-defaults.md` |
42
+ | `/uploads/*` | GET | `handleUploadsRoute` | Avatar bytes via `@luckystack/core` `serveAvatar` | `404` from `serveAvatar` when missing |
43
+ | `/auth/api/email-code/request` | POST | `handleAuthEmailCodeRoute` | `200 { status: true }` (anti-enumeration: also for unknown addresses) | `200 { status:false, reason }`, `429` per-IP |
44
+ | `/auth/api/email-code/verify` | POST | `handleAuthEmailCodeRoute` | Login envelope + session transport, or 2FA-challenge envelope | `200 { status:false, reason }`, `429` per-IP |
45
+ | `/auth/api/2fa` | POST | `handleAuthTwoFactorRoute` | Login envelope + session transport (completes the challenge) | `200 { status:false, reason }`, `429` per-IP |
46
+ | `/auth/api/2fa/email-code` | POST | `handleAuthTwoFactorRoute` | `200 { status: true }` (fallback code sent) | `200 { status:false, reason }`, `429` per-IP |
47
+ | `/auth/api/2fa/setup\|enable\|disable\|recovery-codes` | POST (authed) | `handleAuthTwoFactorRoute` | `200 { status:true, secret/otpauthUri \| recoveryCodes[] }` | `401 api.unauthorized` without session; `200 { status:false, reason }` |
48
+ | `/auth/api/*` | POST | `handleAuthApiRoute` | OAuth redirect (`302`) or credentials login envelope (may carry `requiresTwoFactor` + `challengeToken`) | Rate-limited envelope, `200` envelope with `status:false, reason` |
49
+ | `/auth/callback/*` | GET | `handleAuthCallbackRoute` | `302` to `redirectUrl` with session cookie or token | `401 'Login failed'` plain text |
50
+ | `/api/*` | GET / POST / PUT / DELETE | `handleApiRoute` | JSON envelope from `@luckystack/api` `handleHttpApiRequest`, or SSE stream | `400 api.invalidName`, `500 api.invalidRequestFormat` |
51
+ | `/sync/*` | POST | `handleSyncRoute` | JSON envelope from `@luckystack/sync` `handleHttpSyncRequest`, or SSE stream | `405 sync.methodNotAllowed`, `400 sync.invalidName`, `500 sync.invalidRequestFormat` |
52
+ | `/assets/*`, `*.{png,jpg,jpeg,gif,svg,html,css,js}` | GET | `handleStaticAndSpaFallback` | Whatever `options.serveFile` writes (URL temporarily rewritten) | `404` when no `serveFile` is wired |
53
+ | Any other extension-less path | GET | `handleStaticAndSpaFallback` | SPA fallback (rewrite URL to `/`, call `serveFile`) | `404` when no `serveFile` |
54
+
55
+ > The CSRF header name clients must send (for the `/auth/csrf` route above) is configurable: it comes from `getCsrfConfig().headerName` (default `x-csrf-token`), renamable via `registerCsrfConfig({ headerName })` from `@luckystack/core`.
56
+
57
+ ## API Reference — handlers
58
+
59
+ All handlers conform to:
60
+
61
+ ```typescript
62
+ type HttpRouteHandler = (ctx: HttpRouteContext) => Promise<boolean>;
63
+
64
+ interface HttpRouteContext {
65
+ req: IncomingMessage;
66
+ res: ServerResponse;
67
+ options: CreateLuckyStackServerOptions;
68
+ routePath: string;
69
+ queryString: string | undefined;
70
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
71
+ token: string | null;
72
+ requestId: string;
73
+ sessionCookieOptions: string;
74
+ params: object;
75
+ }
76
+ ```
77
+
78
+ A handler returns `true` (or simply ends `res`) to short-circuit dispatch; returning `false` falls through to the next handler.
79
+
80
+ ### `handleLivezRoute(ctx)`
81
+
82
+ **Behavior:**
83
+
84
+ - Matches when `routePath === projectConfig.http.liveEndpoint` (default `/livez`).
85
+ - Always emits `200 application/json { status: 'live' }`.
86
+
87
+ **Edge cases:** no downstream dependencies; succeeds as long as the Node event loop is responsive.
88
+
89
+ ### `handleReadyzRoute(ctx)`
90
+
91
+ **Behavior:**
92
+
93
+ - Matches when `routePath === projectConfig.http.readyEndpoint` (default `/readyz`).
94
+ - Sequentially checks: `readBootUuid()` (Redis), `redis.ping()`, and a database check (ADR 0020):
95
+ 1. a consumer-registered `registerDbHealthCheck(...)` probe (from `@luckystack/core`) wins;
96
+ 2. otherwise the built-in Prisma probe runs when Prisma is part of the install (`isPrismaClientRegistered()` or a resolvable `@prisma/client`) — provider-agnostic: tries `$queryRaw\`SELECT 1\`` first, then `$runCommandRaw({ ping: 1 })` (detecting the active provider via private fields drifts between Prisma majors, so it probes by capability);
97
+ 3. otherwise the check reports `'skipped'` — a deliberately DB-less project (`orm: 'none'`) can still go ready.
98
+ - Returns `200 { status: 'ready', checks }` when boot UUID + Redis pass and the database check is not `false`; `503 { status: 'not-ready', ... }` otherwise. `checks.database` carries the tri-state (`true | false | 'skipped'`); `checks.prisma` is kept for backward compatibility (`true` when the database check passed or was skipped).
99
+
100
+ **Edge cases:**
101
+
102
+ - `redis.ping()` errors are swallowed and surface as `redis: false`.
103
+ - A Prisma client with neither raw method available results in `prismaOk = false`.
104
+
105
+ ### `handleHealthRoute(ctx)`
106
+
107
+ **Behavior:**
108
+
109
+ - Matches when `routePath === projectConfig.http.healthEndpoint` (default `/_health`).
110
+ - Reads `bootUuid`, `resolveEnvKey()`, and `computeSynchronizedEnvHashes()` from `@luckystack/core`.
111
+ - Returns `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` when boot UUID is present, otherwise `503 { status: 'degraded', ... }`.
112
+ - Consumed by `@luckystack/router` to verify shared-Redis topology after a rolling restart.
113
+
114
+ ### `handleCsrfRoute(ctx)`
115
+
116
+ **Behavior:**
117
+
118
+ - Matches `routePath === '/auth/csrf'`.
119
+ - Requires a session token (cookie or `Authorization`). Returns `401 auth.unauthenticated` when missing or when `getSession(token)` does not resolve a valid session.
120
+ - On success returns `200 { status: 'success', csrfToken }`.
121
+
122
+ **Note:** the matching middleware is `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). This route only issues tokens.
123
+
124
+ ### `handleFaviconRoute(ctx)`
125
+
126
+ **Behavior:**
127
+
128
+ - Matches `routePath === '/favicon.ico'`.
129
+ - Delegates to `options.serveFavicon(res)`. Without that option, returns an empty `404`.
130
+
131
+ ### `handleUploadsRoute(ctx)`
132
+
133
+ **Behavior:**
134
+
135
+ - Matches every path starting with `/uploads/`.
136
+ - Delegates to `serveAvatar({ routePath, res })` from `@luckystack/core`. Response (and `404` on miss) is owned by `serveAvatar`.
137
+
138
+ ### `handleAuthApiRoute(ctx)`
139
+
140
+ **Behavior:**
141
+
142
+ - Matches `routePath` starting with `/auth/api`.
143
+ - Looks up the provider via `getOAuthProviders().find((p) => p.name === routePath.split('/')[3])`.
144
+ - For full OAuth providers (`isFullOAuthProvider(provider)`):
145
+ - `createOAuthState(provider.name)` produces a CSRF state token; failure returns `500 login.oauthStateInitFailed`.
146
+ - Builds the provider's authorization URL with `client_id`, `redirect_uri`, `scope`, `response_type=code`, `prompt=select_account`, `state`, and emits a `302`.
147
+ - For credentials provider:
148
+ - Applies an IP-keyed rate limit (`key: ip:<remote>:auth:credentials`) using `projectConfig.rateLimiting.defaultApiLimit` + `windowMs`. On hit, dispatches `rateLimitExceeded` hook and returns `{ status: false, reason: 'api.rateLimitExceeded', errorParams: [{ key: 'seconds', value: resetIn }] }`.
149
+ - Calls `loginWithCredentials(params)`. On success: delete existing session, choose cookie-based vs header-based token via `x-session-based-token` header (overrides `projectConfig.session.basedToken`), then emit `Set-Cookie` or `X-Session-Token`.
150
+ - Final envelope: `{ status, reason, session, authenticated }`.
151
+
152
+ **Errors:** unknown provider -> `{ status: false, reason: 'login.providerNotFound' }`; thrown errors bubble to the outer `httpHandler` error path.
153
+
154
+ ### `handleAuthCallbackRoute(ctx)`
155
+
156
+ **Behavior:**
157
+
158
+ - Matches `routePath` starting with `/auth/callback`.
159
+ - Computes `baseLocation = projectConfig.app.publicUrl || '/'` (the public origin where users browse; the OAuth callback is handled on the backend origin but redirects the browser back here). `||` so an empty `publicUrl` falls through to `/`.
160
+ - Delegates to `loginCallback(routePath, req, res, { defaultRedirectUrl })` from `@luckystack/login`.
161
+ - On failure: `401 'Login failed'`.
162
+ - On success: delete existing session, then redirect (`302`). With `projectConfig.session.basedToken: true` the token rides as `?token=...` in the query; otherwise it's emitted as a session cookie.
163
+
164
+ ### `handleApiRoute(ctx)`
165
+
166
+ **Behavior:**
167
+
168
+ - Matches every path starting with `/api/`.
169
+ - Detects SSE intent via `shouldUseHttpStream({ acceptHeader, queryString })`: returns true if `Accept` contains `text/event-stream` or `?<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). When true, initializes an SSE response header and tracks client disconnect via `req.on('close', ...)`.
170
+ - Strips `/api/` prefix; empty name -> `400 api.invalidName` (or SSE `final` event with same payload).
171
+ - Calls `handleHttpApiRequest({ name, data, token, requesterIp, xLanguageHeader, acceptLanguageHeader, method, stream? })` from `@luckystack/api`. The `stream` callback emits `event: stream` SSE frames during long-running calls.
172
+ - Emits the result (`event: final` for SSE, or `JSON.stringify(result)` + `res.writeHead(result.httpStatus)` for non-SSE).
173
+
174
+ **Errors:** any thrown error is logged + captured to Sentry, `apiError` hook dispatched, and the envelope `500 api.invalidRequestFormat` is returned (over SSE or JSON).
175
+
176
+ ### `handleSyncRoute(ctx)`
177
+
178
+ **Behavior:**
179
+
180
+ - Matches every path starting with `/sync/`.
181
+ - Only `POST` is allowed; other methods return `405 sync.methodNotAllowed`.
182
+ - Normalizes params into `{ data, receiver, ignoreSelf?, cb? }`. Empty `receiver` is allowed (`''`).
183
+ - Calls `handleHttpSyncRequest({ name: 'sync/<rest>', cb, data, receiver, ignoreSelf, token, requesterIp, xLanguageHeader, acceptLanguageHeader, stream? })` from `@luckystack/sync`.
184
+ - SSE handling mirrors `handleApiRoute`.
185
+
186
+ **Errors:** mirrors `handleApiRoute` — logs, Sentry, `syncError` hook, returns `500 sync.invalidRequestFormat`.
187
+
188
+ ### `handleCustomRoutes(ctx)`
189
+
190
+ **Behavior:**
191
+
192
+ - Iterates every handler registered via `registerCustomRoute(...)` in registration order; first one to return `true` or end the response wins.
193
+ - If none handled, falls back to the inline `options.customRoutes` if provided.
194
+ - Each handler is wrapped in `tryCatch` — on throw it logs via `getLogger().error`, calls `captureException`, and emits `500 server.customRouteFailed` (returning `true` so dispatch stops).
195
+
196
+ ### `handleStaticAndSpaFallback(ctx)`
197
+
198
+ **Behavior:**
199
+
200
+ - `/assets/*` paths: matched via `startsWith('/assets/')`; the (percent-decoded) routePath is rejected with `404` when it contains `..` (path-traversal guard at the framework boundary), then `req.url` is temporarily rewritten to the routePath, `options.serveFile` is called, and `req.url` restored. Without `serveFile`, returns `404 Not Found`.
201
+ - Paths matching `KNOWN_STATIC_FILE_REGEX` (`/^\/(assets\/[a-zA-Z0-9_\-/]+|[a-zA-Z0-9_\-]+)\.(png|jpg|jpeg|gif|svg|html|css|js)$/`): call `serveFile` directly without URL rewrite.
202
+ - Other paths that have an extension `path.extname(routePath)`: `404 Not Found` (text/plain).
203
+ - Extensionless paths (SPA routes): rewrite `req.url` to `/`, call `serveFile`. Without `serveFile`, returns `404 Not Found`.
204
+
205
+ **Why the rewrite around `serveFile`:** consumers (Vite, custom static middleware) read `req.url`. We swap then restore so downstream loggers / metrics see the original URL.
206
+
207
+ ## Custom-route registry
208
+
209
+ ```typescript
210
+ export const registerCustomRoute: (handler: CustomRouteHandler) => void;
211
+ export const getCustomRoutes: () => readonly CustomRouteHandler[];
212
+ export const clearCustomRoutes: () => void;
213
+ ```
214
+
215
+ - `registerCustomRoute(handler)` — appends to an in-memory array. Called by overlay packages (`@luckystack/docs-ui` mounts `/_docs` this way).
216
+ - `getCustomRoutes()` — read snapshot; iterated by `handleCustomRoutes`.
217
+ - `clearCustomRoutes()` — empty the array; used by `@luckystack/test-runner`'s reset flow.
218
+
219
+ The handler signature:
220
+
221
+ ```typescript
222
+ type CustomRouteHandler = (
223
+ req: IncomingMessage,
224
+ res: ServerResponse,
225
+ ctx: RouteContext,
226
+ ) => Promise<boolean> | boolean;
227
+
228
+ interface RouteContext {
229
+ routePath: string;
230
+ method: string;
231
+ queryString: string | undefined;
232
+ token: string | null;
233
+ }
234
+ ```
235
+
236
+ Return `true` (or end the response) when handled. Throws are caught and emit `500 server.customRouteFailed`.
237
+
238
+ **Example — webhook receiver:**
239
+
240
+ ```typescript
241
+ import { registerCustomRoute } from '@luckystack/server';
242
+
243
+ registerCustomRoute(async (req, res, ctx) => {
244
+ if (ctx.routePath !== '/webhooks/stripe') return false;
245
+ if (ctx.method !== 'POST') {
246
+ res.writeHead(405);
247
+ res.end();
248
+ return true;
249
+ }
250
+ // verify signature, process body ...
251
+ res.writeHead(200);
252
+ res.end('ok');
253
+ return true;
254
+ });
255
+ ```
256
+
257
+ **Example — health alias for an external monitor:**
258
+
259
+ ```typescript
260
+ registerCustomRoute(async (_req, res, ctx) => {
261
+ if (ctx.routePath !== '/healthz') return false;
262
+ res.statusCode = 200;
263
+ res.end('ok');
264
+ return true;
265
+ });
266
+ ```
267
+
268
+ ## SSE streaming
269
+
270
+ `/api/*` and `/sync/*` both support Server-Sent Events when the client opts in. Helpers live in `packages/server/src/sse.ts`:
271
+
272
+ | Helper | Behavior |
273
+ | --- | --- |
274
+ | `shouldUseHttpStream({ acceptHeader, queryString })` | Returns `true` if `Accept` includes `text/event-stream` OR the query string contains `<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). |
275
+ | `initSseResponse(res)` | Writes `200` with `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`, `X-Accel-Buffering: no`. Emits `projectConfig.http.stream.connectedComment` as a keepalive comment if configured. |
276
+ | `sendSseEvent({ res, event, data })` | Writes `event: <event>\ndata: <json>\n\n`. No-op once `res.writableEnded`. |
277
+
278
+ Lifecycle in `handleApiRoute` / `handleSyncRoute`:
279
+
280
+ 1. Decide SSE vs JSON via `shouldUseHttpStream`.
281
+ 2. If SSE, call `initSseResponse(res)` and `req.on('close', () => { streamClosed = true; })`.
282
+ 3. Pass a `stream` callback to `handleHttpApiRequest` / `handleHttpSyncRequest` that emits `event: stream` frames while the request is processing.
283
+ 4. On completion, emit `event: final` with the result envelope and call `res.end()`.
284
+ 5. On error, emit `event: error` with the `500 *.invalidRequestFormat` envelope.
285
+
286
+ ## Hooks dispatched
287
+
288
+ | Hook | Payload | When |
289
+ | --- | --- | --- |
290
+ | `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | `handleAuthApiRoute` credentials path when IP limit is exceeded. |
291
+ | `apiError` | `{ route, method, requestId, error }` | `handleApiRoute` outer error path. |
292
+ | `syncError` | `{ route, method, requestId, error }` | `handleSyncRoute` outer error path. |
293
+ | `csrfMismatch` | `{ route, method, requestId, userId, providedToken }` | `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). |
294
+ | `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Every incoming request before dispatch (see `request-pipeline.md`). |
295
+
296
+ ## Config keys consumed
297
+
298
+ | Source | Key | Effect |
299
+ | --- | --- | --- |
300
+ | config | `projectConfig.http.healthEndpoint` | Default `/_health`. |
301
+ | config | `projectConfig.http.liveEndpoint` | Default `/livez`. |
302
+ | config | `projectConfig.http.readyEndpoint` | Default `/readyz`. |
303
+ | config | `projectConfig.http.testResetEndpoint` | Default `/_test/reset`. |
304
+ | config | `projectConfig.http.stream.queryParam` / `enabledValue` / `connectedComment` | SSE opt-in query parameter + initial keepalive comment. |
305
+ | config | `projectConfig.http.sessionCookieName` | Cookie name written by `/auth/api` / `/auth/callback`. |
306
+ | config | `projectConfig.session.basedToken` | Cookie vs header token transport. |
307
+ | config | `projectConfig.rateLimiting.defaultApiLimit` / `windowMs` | Credentials login rate limit. |
308
+ | config | `projectConfig.app.publicUrl` | Public origin the OAuth callback redirects the browser back to. |
309
+ | env | `TEST_RESET_TOKEN` | Consumed by `/_test/reset` — see `security-defaults.md`. |
310
+
311
+ ## Related
312
+
313
+ - Function INDEX: `packages/server/CLAUDE.md`
314
+ - Request pipeline: `packages/server/docs/request-pipeline.md`
315
+ - Security defaults: `packages/server/docs/security-defaults.md`
316
+ - API delegate: `docs/ARCHITECTURE_API.md`
317
+ - Sync delegate: `docs/ARCHITECTURE_SYNC.md`
318
+ - README: `packages/server/README.md`