@luckystack/server 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +87 -85
- package/LICENSE +21 -21
- package/README.md +198 -198
- package/dist/{chunk-X3OSC5W3.js → chunk-RIVFTOTI.js} +1 -1
- package/dist/chunk-RIVFTOTI.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +90 -21
- package/dist/index.js.map +1 -1
- package/dist/parseArgv.js +1 -1
- package/dist/parseArgv.js.map +1 -1
- package/docs/argv-parsing.md +256 -256
- package/docs/create-server.md +295 -295
- package/docs/http-routes.md +311 -309
- package/docs/request-pipeline.md +198 -198
- package/docs/runtime-maps.md +206 -206
- package/docs/security-defaults.md +236 -236
- package/package.json +13 -10
- package/dist/chunk-X3OSC5W3.js.map +0 -1
|
@@ -1,236 +1,236 @@
|
|
|
1
|
-
# Security Defaults
|
|
2
|
-
|
|
3
|
-
> Deep specs. Bron: `packages/server/src/httpHandler.ts`, `packages/server/src/httpRoutes/csrfMiddleware.ts`, `packages/server/src/httpRoutes/testResetRoute.ts`, `packages/server/src/securityHeadersRegistry.ts`, `packages/server/src/errorFormatterRegistry.ts`. Bijgewerkt: 2026-05-20.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
`@luckystack/server` ships four fail-closed defenses out of the box:
|
|
8
|
-
|
|
9
|
-
1. **Origin policy.** No `Host` fallback — non-browser callers cannot silently bypass CORS on state-changing methods.
|
|
10
|
-
2. **Security headers.** Framework defaults are emitted for every response. A consumer-registered builder may override or extend.
|
|
11
|
-
3. **CSRF middleware.** Cookie-mode state-changing requests to `/api/*`, `/sync/*`, and `/auth/api/*` require a CSRF header (default `x-csrf-token`, renamable via `registerCsrfConfig({ headerName })` from `@luckystack/core`) matching the session-bound token; mismatches return 403 and dispatch the `csrfMismatch` hook.
|
|
12
|
-
4. **`/_test/reset` fail-closed.** Requires `NODE_ENV` to be exactly `development` or `test` AND a non-empty `TEST_RESET_TOKEN` env var. An unset token does NOT mean "no auth"; it means the route is permanently 403.
|
|
13
|
-
|
|
14
|
-
Plus two extension seams:
|
|
15
|
-
|
|
16
|
-
- `registerSecurityHeaders(builder)` — append / override headers per request.
|
|
17
|
-
- `registerErrorFormatter(formatter)` — shape the JSON error envelope globally.
|
|
18
|
-
|
|
19
|
-
## CORS / origin policy
|
|
20
|
-
|
|
21
|
-
Lives in `enforceOriginPolicy` (`httpHandler.ts`):
|
|
22
|
-
|
|
23
|
-
- `origin = req.headers.origin ?? req.headers.referer ?? ''`. There is intentionally NO `Host` fallback.
|
|
24
|
-
- For state-changing methods (anything other than GET / HEAD / OPTIONS) with no `Origin` and no `Referer`: respond `403 'Forbidden'` (text/plain) and stop dispatch.
|
|
25
|
-
- For any method when an explicit `Origin` / `Referer` is supplied but `allowedOrigin(origin)` returns false: respond `403 'Forbidden'` and stop.
|
|
26
|
-
- Read-only methods (GET / HEAD / OPTIONS) without an origin header pass through — this keeps health probes and asset fetches from non-browser tooling working.
|
|
27
|
-
|
|
28
|
-
When `curl`-testing a write endpoint, attach `-H 'Origin: https://your-allowed-origin'` (the allow-list lives in `projectConfig.http.cors.allowedOrigins` and is enforced by `allowedOrigin` in `@luckystack/core`).
|
|
29
|
-
|
|
30
|
-
## Security headers
|
|
31
|
-
|
|
32
|
-
`setSecurityHeaders` in `httpHandler.ts` writes the framework defaults from `projectConfig.http`:
|
|
33
|
-
|
|
34
|
-
| Header | Source | Notes |
|
|
35
|
-
| --- | --- | --- |
|
|
36
|
-
| `Access-Control-Allow-Origin` | resolved `origin` (or `''`) | Mirrors the origin policy's resolved value. |
|
|
37
|
-
| `Access-Control-Allow-Methods` | `cors.allowedMethods` | |
|
|
38
|
-
| `Access-Control-Allow-Headers` | `cors.allowedHeaders` | |
|
|
39
|
-
| `Access-Control-Expose-Headers` | `cors.exposedHeaders` | |
|
|
40
|
-
| `Access-Control-Allow-Credentials` | `cors.credentials ? 'true' : (omitted)` | |
|
|
41
|
-
| `Referrer-Policy` | `securityHeaders.referrerPolicy` | |
|
|
42
|
-
| `X-Frame-Options` | `securityHeaders.frameOptions` | |
|
|
43
|
-
| `X-XSS-Protection` | `securityHeaders.xssProtection` | |
|
|
44
|
-
| `X-Content-Type-Options` | `securityHeaders.contentTypeOptions` | |
|
|
45
|
-
|
|
46
|
-
After defaults, the registered builder (if any) runs. Errors in the builder are caught and logged via `getLogger().warn('securityHeadersBuilder threw — falling back to defaults', { err })`; the request continues with defaults intact.
|
|
47
|
-
|
|
48
|
-
### API — `registerSecurityHeaders(builder | null)`
|
|
49
|
-
|
|
50
|
-
**Signature:**
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
export type SecurityHeadersBuilder = (
|
|
54
|
-
req: IncomingMessage,
|
|
55
|
-
) => Record<string, string> | null | undefined;
|
|
56
|
-
|
|
57
|
-
export const registerSecurityHeaders = (builder: SecurityHeadersBuilder | null): void;
|
|
58
|
-
export const getSecurityHeadersBuilder = (): SecurityHeadersBuilder | null;
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
**Behavior:**
|
|
62
|
-
|
|
63
|
-
- Last-write-wins; subsequent calls replace the prior builder. Pass `null` to unregister.
|
|
64
|
-
- Builder is invoked for every request after framework defaults. A returned object's entries are written via `res.setHeader(name, value)` so they REPLACE same-name defaults (per Node semantics).
|
|
65
|
-
- A nullish return (`null` / `undefined`) means "use defaults only".
|
|
66
|
-
|
|
67
|
-
**Example:**
|
|
68
|
-
|
|
69
|
-
```typescript
|
|
70
|
-
import { registerSecurityHeaders } from '@luckystack/server';
|
|
71
|
-
|
|
72
|
-
registerSecurityHeaders((req) => ({
|
|
73
|
-
'Content-Security-Policy': "default-src 'self'; img-src 'self' data:; script-src 'self'",
|
|
74
|
-
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
75
|
-
'Permissions-Policy': 'camera=(), microphone=()',
|
|
76
|
-
}));
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
## CSRF middleware
|
|
80
|
-
|
|
81
|
-
Lives in `enforceCsrfOnStateChangingRequest` (`csrfMiddleware.ts`). Runs after origin/security-header setup but BEFORE route dispatch.
|
|
82
|
-
|
|
83
|
-
**Activation predicate (all required):**
|
|
84
|
-
|
|
85
|
-
- `projectConfig.session.basedToken === false` (cookie mode).
|
|
86
|
-
- HTTP method is NOT `GET` and NOT `OPTIONS`.
|
|
87
|
-
- `routePath` starts with `/api/`, `/sync/`, or `/auth/api/`.
|
|
88
|
-
- `routePath` does NOT start with `/auth/callback`.
|
|
89
|
-
- A session token was extracted from the request.
|
|
90
|
-
|
|
91
|
-
When the predicate is true:
|
|
92
|
-
|
|
93
|
-
1. `getSession(token)` resolves the session. If no session, skip middleware (downstream auth check rejects).
|
|
94
|
-
2. Read the configured CSRF header (`getCsrfConfig().headerName`, default `x-csrf-token`; first value when array).
|
|
95
|
-
3. If provided and equal to `session.csrfToken`, allow.
|
|
96
|
-
4. Otherwise: dispatch the `csrfMismatch` hook with `{ route, method, requestId, userId, providedToken: Boolean(provided) }` (note: presence-only, never the value), then respond `403 application/json { status: 'error', errorCode: 'auth.csrfMismatch', message: 'CSRF token missing or invalid. Fetch /auth/csrf first.' }`.
|
|
97
|
-
|
|
98
|
-
Token issue: `GET /auth/csrf` (`csrfRoute.ts`) returns `{ status: 'success', csrfToken }`. The `apiRequest` helper in `@luckystack/core/client` fetches it once per session and attaches it to every state-changing request automatically.
|
|
99
|
-
|
|
100
|
-
## `/_test/reset` fail-closed contract
|
|
101
|
-
|
|
102
|
-
Source: `httpRoutes/testResetRoute.ts`.
|
|
103
|
-
|
|
104
|
-
**Activation predicate (both required):**
|
|
105
|
-
|
|
106
|
-
1. `process.env.NODE_ENV` is exactly `'development'` OR `'test'`. Any other value (including unset / `'production'` / `'staging'`) returns `404 { status: 'error', errorCode: 'notFound' }`. This is the fail-closed gate — `NODE_ENV !== 'production'` is NOT a sufficient check.
|
|
107
|
-
2. `process.env.TEST_RESET_TOKEN` is non-empty AND `req.headers['x-test-reset-token'] === process.env.TEST_RESET_TOKEN`. Any mismatch (including an unset token) returns `403 { status: 'error', errorCode: 'auth.forbidden' }`.
|
|
108
|
-
|
|
109
|
-
**Authorization header — actual source-of-truth:**
|
|
110
|
-
|
|
111
|
-
The handler reads `req.headers['x-test-reset-token']`. Wire your test client to send:
|
|
112
|
-
|
|
113
|
-
```
|
|
114
|
-
x-test-reset-token: <value of TEST_RESET_TOKEN>
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
(The README quickstart mentions `Authorization: Bearer ${TOKEN}` — the running code reads `x-test-reset-token`. Use that header.)
|
|
118
|
-
|
|
119
|
-
**What the reset clears:**
|
|
120
|
-
|
|
121
|
-
| Step | Action |
|
|
122
|
-
| --- | --- |
|
|
123
|
-
| 1 | `clearAllRateLimits()` — purges in-memory + Redis rate-limit counters. Always reported in `cleared`. |
|
|
124
|
-
| 2 | `redis SCAN` + `DEL` keys matching `<projectName>-session:*`. Reported as `'sessions'` when at least one key is deleted. |
|
|
125
|
-
| 3 | `redis SCAN` + `DEL` keys matching `<projectName>-activeUsers:*`. Reported as `'activeUsers'` when at least one key is deleted. |
|
|
126
|
-
| 4 (opt-in) | When `?include=hooks` is supplied, `clearAllHooks()` empties every framework hook registration. Reported as `'hooks'`. |
|
|
127
|
-
|
|
128
|
-
`getProjectName()` (from `@luckystack/core`) is the single source of truth for the key prefix — same helper used by `session.ts` and `rateLimiter.ts`.
|
|
129
|
-
|
|
130
|
-
**Response:** `200 application/json { status: 'success', cleared: string[] }`. The `cleared` array reflects which subsystems reported deletions.
|
|
131
|
-
|
|
132
|
-
**Errors / edge cases:**
|
|
133
|
-
|
|
134
|
-
- Malformed `req.url` is detected with `URL.canParse(rawUrl, base)`. When false, `?include=hooks` is treated as absent.
|
|
135
|
-
- Redis `SCAN`+`DEL` errors are swallowed per pattern — the cleared array simply omits the label.
|
|
136
|
-
- The hook-clear is opt-in because clearing every hook also removes framework-internal registrations (e.g. presence's `postLogout`).
|
|
137
|
-
|
|
138
|
-
**Example — invoking from a test runner:**
|
|
139
|
-
|
|
140
|
-
```bash
|
|
141
|
-
curl -X POST http://127.0.0.1:80/_test/reset \
|
|
142
|
-
-H 'x-test-reset-token: dev-secret-123' \
|
|
143
|
-
-H 'Origin: http://127.0.0.1:80'
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
`.env.local` (dev / test only):
|
|
147
|
-
|
|
148
|
-
```
|
|
149
|
-
NODE_ENV=development
|
|
150
|
-
TEST_RESET_TOKEN=dev-secret-123
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
`@luckystack/test-runner`'s `resetServerState` reads the same env var.
|
|
154
|
-
|
|
155
|
-
## Error formatter
|
|
156
|
-
|
|
157
|
-
Lives in `errorFormatterRegistry.ts`. The framework normalizes errors via `normalizeErrorResponse` from `@luckystack/core` (i18n + envelope shape). The registered formatter is the GLOBAL hook — per-endpoint `errorFormatter` exports on route files take precedence.
|
|
158
|
-
|
|
159
|
-
**Resolution order at error time:**
|
|
160
|
-
|
|
161
|
-
1. Per-endpoint `errorFormatter` export (when the route file declares one).
|
|
162
|
-
2. Global formatter from `registerErrorFormatter(...)`.
|
|
163
|
-
3. Framework default `normalizeErrorResponse`.
|
|
164
|
-
|
|
165
|
-
### API — `registerErrorFormatter(formatter | null)`
|
|
166
|
-
|
|
167
|
-
**Signature:**
|
|
168
|
-
|
|
169
|
-
```typescript
|
|
170
|
-
export interface ErrorFormatterContext {
|
|
171
|
-
routeName: string;
|
|
172
|
-
transport: 'socket' | 'http';
|
|
173
|
-
userId?: string | null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export type ErrorFormatter = (
|
|
177
|
-
error: {
|
|
178
|
-
status: 'error';
|
|
179
|
-
errorCode: string;
|
|
180
|
-
message?: string;
|
|
181
|
-
httpStatus?: number;
|
|
182
|
-
[key: string]: unknown;
|
|
183
|
-
},
|
|
184
|
-
ctx: ErrorFormatterContext,
|
|
185
|
-
) => Record<string, unknown>;
|
|
186
|
-
|
|
187
|
-
export const registerErrorFormatter = (formatter: ErrorFormatter | null): void;
|
|
188
|
-
export const getErrorFormatter = (): ErrorFormatter | null;
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
**Behavior:**
|
|
192
|
-
|
|
193
|
-
- Receives the already-normalized envelope plus `{ routeName, transport, userId? }`.
|
|
194
|
-
- Return a (possibly extended) object that the framework emits verbatim.
|
|
195
|
-
- Pass `null` to unregister.
|
|
196
|
-
|
|
197
|
-
**Example — add correlation IDs + a legacy alias key:**
|
|
198
|
-
|
|
199
|
-
```typescript
|
|
200
|
-
import { registerErrorFormatter } from '@luckystack/server';
|
|
201
|
-
|
|
202
|
-
registerErrorFormatter((error, ctx) => ({
|
|
203
|
-
...error,
|
|
204
|
-
// legacy clients expect `reason`; new clients read `errorCode`.
|
|
205
|
-
reason: error.errorCode,
|
|
206
|
-
correlationId: ctx.routeName + ':' + (ctx.userId ?? 'anon'),
|
|
207
|
-
}));
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
## Hooks dispatched
|
|
211
|
-
|
|
212
|
-
| Hook | Payload | When |
|
|
213
|
-
| --- | --- | --- |
|
|
214
|
-
| `csrfMismatch` | `{ route, method, requestId, userId, providedToken: boolean }` | CSRF middleware rejects a write. `providedToken` is `Boolean(value)` — never the token itself. |
|
|
215
|
-
| `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Every request before dispatch; can stop with `HookStopSignal`. `headers` excludes `authorization`, `cookie`, `set-cookie`, `x-csrf-token`. |
|
|
216
|
-
| `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | Credentials login (`handleAuthApiRoute`). |
|
|
217
|
-
|
|
218
|
-
## Config keys
|
|
219
|
-
|
|
220
|
-
| Source | Key | Effect |
|
|
221
|
-
| --- | --- | --- |
|
|
222
|
-
| env | `NODE_ENV` | Gates `/_test/reset` — must be exactly `'development'` or `'test'`. |
|
|
223
|
-
| env | `TEST_RESET_TOKEN` | Required for `/_test/reset` to be reachable. Compared against `x-test-reset-token`. |
|
|
224
|
-
| env | `SECURE` | When `'true'`, session cookies are emitted with `Secure;`. |
|
|
225
|
-
| config | `projectConfig.http.cors.*` | `Access-Control-*` headers. |
|
|
226
|
-
| config | `projectConfig.http.securityHeaders.*` | `Referrer-Policy`, `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`. |
|
|
227
|
-
| config | `projectConfig.session.basedToken` | Switches cookie-mode (CSRF enforced) vs header-mode (CSRF skipped — header transport already binds to the request). |
|
|
228
|
-
| config | `projectConfig.http.testResetEndpoint` | Path for `handleTestResetRoute`. Default `/_test/reset`. |
|
|
229
|
-
|
|
230
|
-
## Related
|
|
231
|
-
|
|
232
|
-
- Function INDEX: `packages/server/CLAUDE.md`
|
|
233
|
-
- Request pipeline: `packages/server/docs/request-pipeline.md`
|
|
234
|
-
- HTTP routes: `packages/server/docs/http-routes.md`
|
|
235
|
-
- Architecture: `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_AUTH.md`
|
|
236
|
-
- README: `packages/server/README.md`
|
|
1
|
+
# Security Defaults
|
|
2
|
+
|
|
3
|
+
> Deep specs. Bron: `packages/server/src/httpHandler.ts`, `packages/server/src/httpRoutes/csrfMiddleware.ts`, `packages/server/src/httpRoutes/testResetRoute.ts`, `packages/server/src/securityHeadersRegistry.ts`, `packages/server/src/errorFormatterRegistry.ts`. Bijgewerkt: 2026-05-20.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@luckystack/server` ships four fail-closed defenses out of the box:
|
|
8
|
+
|
|
9
|
+
1. **Origin policy.** No `Host` fallback — non-browser callers cannot silently bypass CORS on state-changing methods.
|
|
10
|
+
2. **Security headers.** Framework defaults are emitted for every response. A consumer-registered builder may override or extend.
|
|
11
|
+
3. **CSRF middleware.** Cookie-mode state-changing requests to `/api/*`, `/sync/*`, and `/auth/api/*` require a CSRF header (default `x-csrf-token`, renamable via `registerCsrfConfig({ headerName })` from `@luckystack/core`) matching the session-bound token; mismatches return 403 and dispatch the `csrfMismatch` hook.
|
|
12
|
+
4. **`/_test/reset` fail-closed.** Requires `NODE_ENV` to be exactly `development` or `test` AND a non-empty `TEST_RESET_TOKEN` env var. An unset token does NOT mean "no auth"; it means the route is permanently 403.
|
|
13
|
+
|
|
14
|
+
Plus two extension seams:
|
|
15
|
+
|
|
16
|
+
- `registerSecurityHeaders(builder)` — append / override headers per request.
|
|
17
|
+
- `registerErrorFormatter(formatter)` — shape the JSON error envelope globally.
|
|
18
|
+
|
|
19
|
+
## CORS / origin policy
|
|
20
|
+
|
|
21
|
+
Lives in `enforceOriginPolicy` (`httpHandler.ts`):
|
|
22
|
+
|
|
23
|
+
- `origin = req.headers.origin ?? req.headers.referer ?? ''`. There is intentionally NO `Host` fallback.
|
|
24
|
+
- For state-changing methods (anything other than GET / HEAD / OPTIONS) with no `Origin` and no `Referer`: respond `403 'Forbidden'` (text/plain) and stop dispatch.
|
|
25
|
+
- For any method when an explicit `Origin` / `Referer` is supplied but `allowedOrigin(origin)` returns false: respond `403 'Forbidden'` and stop.
|
|
26
|
+
- Read-only methods (GET / HEAD / OPTIONS) without an origin header pass through — this keeps health probes and asset fetches from non-browser tooling working.
|
|
27
|
+
|
|
28
|
+
When `curl`-testing a write endpoint, attach `-H 'Origin: https://your-allowed-origin'` (the allow-list lives in `projectConfig.http.cors.allowedOrigins` and is enforced by `allowedOrigin` in `@luckystack/core`).
|
|
29
|
+
|
|
30
|
+
## Security headers
|
|
31
|
+
|
|
32
|
+
`setSecurityHeaders` in `httpHandler.ts` writes the framework defaults from `projectConfig.http`:
|
|
33
|
+
|
|
34
|
+
| Header | Source | Notes |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `Access-Control-Allow-Origin` | resolved `origin` (or `''`) | Mirrors the origin policy's resolved value. |
|
|
37
|
+
| `Access-Control-Allow-Methods` | `cors.allowedMethods` | |
|
|
38
|
+
| `Access-Control-Allow-Headers` | `cors.allowedHeaders` | |
|
|
39
|
+
| `Access-Control-Expose-Headers` | `cors.exposedHeaders` | |
|
|
40
|
+
| `Access-Control-Allow-Credentials` | `cors.credentials ? 'true' : (omitted)` | |
|
|
41
|
+
| `Referrer-Policy` | `securityHeaders.referrerPolicy` | |
|
|
42
|
+
| `X-Frame-Options` | `securityHeaders.frameOptions` | |
|
|
43
|
+
| `X-XSS-Protection` | `securityHeaders.xssProtection` | |
|
|
44
|
+
| `X-Content-Type-Options` | `securityHeaders.contentTypeOptions` | |
|
|
45
|
+
|
|
46
|
+
After defaults, the registered builder (if any) runs. Errors in the builder are caught and logged via `getLogger().warn('securityHeadersBuilder threw — falling back to defaults', { err })`; the request continues with defaults intact.
|
|
47
|
+
|
|
48
|
+
### API — `registerSecurityHeaders(builder | null)`
|
|
49
|
+
|
|
50
|
+
**Signature:**
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
export type SecurityHeadersBuilder = (
|
|
54
|
+
req: IncomingMessage,
|
|
55
|
+
) => Record<string, string> | null | undefined;
|
|
56
|
+
|
|
57
|
+
export const registerSecurityHeaders = (builder: SecurityHeadersBuilder | null): void;
|
|
58
|
+
export const getSecurityHeadersBuilder = (): SecurityHeadersBuilder | null;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Behavior:**
|
|
62
|
+
|
|
63
|
+
- Last-write-wins; subsequent calls replace the prior builder. Pass `null` to unregister.
|
|
64
|
+
- Builder is invoked for every request after framework defaults. A returned object's entries are written via `res.setHeader(name, value)` so they REPLACE same-name defaults (per Node semantics).
|
|
65
|
+
- A nullish return (`null` / `undefined`) means "use defaults only".
|
|
66
|
+
|
|
67
|
+
**Example:**
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { registerSecurityHeaders } from '@luckystack/server';
|
|
71
|
+
|
|
72
|
+
registerSecurityHeaders((req) => ({
|
|
73
|
+
'Content-Security-Policy': "default-src 'self'; img-src 'self' data:; script-src 'self'",
|
|
74
|
+
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
75
|
+
'Permissions-Policy': 'camera=(), microphone=()',
|
|
76
|
+
}));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## CSRF middleware
|
|
80
|
+
|
|
81
|
+
Lives in `enforceCsrfOnStateChangingRequest` (`csrfMiddleware.ts`). Runs after origin/security-header setup but BEFORE route dispatch.
|
|
82
|
+
|
|
83
|
+
**Activation predicate (all required):**
|
|
84
|
+
|
|
85
|
+
- `projectConfig.session.basedToken === false` (cookie mode).
|
|
86
|
+
- HTTP method is NOT `GET` and NOT `OPTIONS`.
|
|
87
|
+
- `routePath` starts with `/api/`, `/sync/`, or `/auth/api/`.
|
|
88
|
+
- `routePath` does NOT start with `/auth/callback`.
|
|
89
|
+
- A session token was extracted from the request.
|
|
90
|
+
|
|
91
|
+
When the predicate is true:
|
|
92
|
+
|
|
93
|
+
1. `getSession(token)` resolves the session. If no session, skip middleware (downstream auth check rejects).
|
|
94
|
+
2. Read the configured CSRF header (`getCsrfConfig().headerName`, default `x-csrf-token`; first value when array).
|
|
95
|
+
3. If provided and equal to `session.csrfToken`, allow.
|
|
96
|
+
4. Otherwise: dispatch the `csrfMismatch` hook with `{ route, method, requestId, userId, providedToken: Boolean(provided) }` (note: presence-only, never the value), then respond `403 application/json { status: 'error', errorCode: 'auth.csrfMismatch', message: 'CSRF token missing or invalid. Fetch /auth/csrf first.' }`.
|
|
97
|
+
|
|
98
|
+
Token issue: `GET /auth/csrf` (`csrfRoute.ts`) returns `{ status: 'success', csrfToken }`. The `apiRequest` helper in `@luckystack/core/client` fetches it once per session and attaches it to every state-changing request automatically.
|
|
99
|
+
|
|
100
|
+
## `/_test/reset` fail-closed contract
|
|
101
|
+
|
|
102
|
+
Source: `httpRoutes/testResetRoute.ts`.
|
|
103
|
+
|
|
104
|
+
**Activation predicate (both required):**
|
|
105
|
+
|
|
106
|
+
1. `process.env.NODE_ENV` is exactly `'development'` OR `'test'`. Any other value (including unset / `'production'` / `'staging'`) returns `404 { status: 'error', errorCode: 'notFound' }`. This is the fail-closed gate — `NODE_ENV !== 'production'` is NOT a sufficient check.
|
|
107
|
+
2. `process.env.TEST_RESET_TOKEN` is non-empty AND `req.headers['x-test-reset-token'] === process.env.TEST_RESET_TOKEN`. Any mismatch (including an unset token) returns `403 { status: 'error', errorCode: 'auth.forbidden' }`.
|
|
108
|
+
|
|
109
|
+
**Authorization header — actual source-of-truth:**
|
|
110
|
+
|
|
111
|
+
The handler reads `req.headers['x-test-reset-token']`. Wire your test client to send:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
x-test-reset-token: <value of TEST_RESET_TOKEN>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
(The README quickstart mentions `Authorization: Bearer ${TOKEN}` — the running code reads `x-test-reset-token`. Use that header.)
|
|
118
|
+
|
|
119
|
+
**What the reset clears:**
|
|
120
|
+
|
|
121
|
+
| Step | Action |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| 1 | `clearAllRateLimits()` — purges in-memory + Redis rate-limit counters. Always reported in `cleared`. |
|
|
124
|
+
| 2 | `redis SCAN` + `DEL` keys matching `<projectName>-session:*`. Reported as `'sessions'` when at least one key is deleted. |
|
|
125
|
+
| 3 | `redis SCAN` + `DEL` keys matching `<projectName>-activeUsers:*`. Reported as `'activeUsers'` when at least one key is deleted. |
|
|
126
|
+
| 4 (opt-in) | When `?include=hooks` is supplied, `clearAllHooks()` empties every framework hook registration. Reported as `'hooks'`. |
|
|
127
|
+
|
|
128
|
+
`getProjectName()` (from `@luckystack/core`) is the single source of truth for the key prefix — same helper used by `session.ts` and `rateLimiter.ts`.
|
|
129
|
+
|
|
130
|
+
**Response:** `200 application/json { status: 'success', cleared: string[] }`. The `cleared` array reflects which subsystems reported deletions.
|
|
131
|
+
|
|
132
|
+
**Errors / edge cases:**
|
|
133
|
+
|
|
134
|
+
- Malformed `req.url` is detected with `URL.canParse(rawUrl, base)`. When false, `?include=hooks` is treated as absent.
|
|
135
|
+
- Redis `SCAN`+`DEL` errors are swallowed per pattern — the cleared array simply omits the label.
|
|
136
|
+
- The hook-clear is opt-in because clearing every hook also removes framework-internal registrations (e.g. presence's `postLogout`).
|
|
137
|
+
|
|
138
|
+
**Example — invoking from a test runner:**
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
curl -X POST http://127.0.0.1:80/_test/reset \
|
|
142
|
+
-H 'x-test-reset-token: dev-secret-123' \
|
|
143
|
+
-H 'Origin: http://127.0.0.1:80'
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`.env.local` (dev / test only):
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
NODE_ENV=development
|
|
150
|
+
TEST_RESET_TOKEN=dev-secret-123
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`@luckystack/test-runner`'s `resetServerState` reads the same env var.
|
|
154
|
+
|
|
155
|
+
## Error formatter
|
|
156
|
+
|
|
157
|
+
Lives in `errorFormatterRegistry.ts`. The framework normalizes errors via `normalizeErrorResponse` from `@luckystack/core` (i18n + envelope shape). The registered formatter is the GLOBAL hook — per-endpoint `errorFormatter` exports on route files take precedence.
|
|
158
|
+
|
|
159
|
+
**Resolution order at error time:**
|
|
160
|
+
|
|
161
|
+
1. Per-endpoint `errorFormatter` export (when the route file declares one).
|
|
162
|
+
2. Global formatter from `registerErrorFormatter(...)`.
|
|
163
|
+
3. Framework default `normalizeErrorResponse`.
|
|
164
|
+
|
|
165
|
+
### API — `registerErrorFormatter(formatter | null)`
|
|
166
|
+
|
|
167
|
+
**Signature:**
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
export interface ErrorFormatterContext {
|
|
171
|
+
routeName: string;
|
|
172
|
+
transport: 'socket' | 'http';
|
|
173
|
+
userId?: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type ErrorFormatter = (
|
|
177
|
+
error: {
|
|
178
|
+
status: 'error';
|
|
179
|
+
errorCode: string;
|
|
180
|
+
message?: string;
|
|
181
|
+
httpStatus?: number;
|
|
182
|
+
[key: string]: unknown;
|
|
183
|
+
},
|
|
184
|
+
ctx: ErrorFormatterContext,
|
|
185
|
+
) => Record<string, unknown>;
|
|
186
|
+
|
|
187
|
+
export const registerErrorFormatter = (formatter: ErrorFormatter | null): void;
|
|
188
|
+
export const getErrorFormatter = (): ErrorFormatter | null;
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Behavior:**
|
|
192
|
+
|
|
193
|
+
- Receives the already-normalized envelope plus `{ routeName, transport, userId? }`.
|
|
194
|
+
- Return a (possibly extended) object that the framework emits verbatim.
|
|
195
|
+
- Pass `null` to unregister.
|
|
196
|
+
|
|
197
|
+
**Example — add correlation IDs + a legacy alias key:**
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
import { registerErrorFormatter } from '@luckystack/server';
|
|
201
|
+
|
|
202
|
+
registerErrorFormatter((error, ctx) => ({
|
|
203
|
+
...error,
|
|
204
|
+
// legacy clients expect `reason`; new clients read `errorCode`.
|
|
205
|
+
reason: error.errorCode,
|
|
206
|
+
correlationId: ctx.routeName + ':' + (ctx.userId ?? 'anon'),
|
|
207
|
+
}));
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Hooks dispatched
|
|
211
|
+
|
|
212
|
+
| Hook | Payload | When |
|
|
213
|
+
| --- | --- | --- |
|
|
214
|
+
| `csrfMismatch` | `{ route, method, requestId, userId, providedToken: boolean }` | CSRF middleware rejects a write. `providedToken` is `Boolean(value)` — never the token itself. |
|
|
215
|
+
| `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Every request before dispatch; can stop with `HookStopSignal`. `headers` excludes `authorization`, `cookie`, `set-cookie`, `x-csrf-token`. |
|
|
216
|
+
| `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | Credentials login (`handleAuthApiRoute`). |
|
|
217
|
+
|
|
218
|
+
## Config keys
|
|
219
|
+
|
|
220
|
+
| Source | Key | Effect |
|
|
221
|
+
| --- | --- | --- |
|
|
222
|
+
| env | `NODE_ENV` | Gates `/_test/reset` — must be exactly `'development'` or `'test'`. |
|
|
223
|
+
| env | `TEST_RESET_TOKEN` | Required for `/_test/reset` to be reachable. Compared against `x-test-reset-token`. |
|
|
224
|
+
| env | `SECURE` | When `'true'`, session cookies are emitted with `Secure;`. |
|
|
225
|
+
| config | `projectConfig.http.cors.*` | `Access-Control-*` headers. |
|
|
226
|
+
| config | `projectConfig.http.securityHeaders.*` | `Referrer-Policy`, `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`. |
|
|
227
|
+
| config | `projectConfig.session.basedToken` | Switches cookie-mode (CSRF enforced) vs header-mode (CSRF skipped — header transport already binds to the request). |
|
|
228
|
+
| config | `projectConfig.http.testResetEndpoint` | Path for `handleTestResetRoute`. Default `/_test/reset`. |
|
|
229
|
+
|
|
230
|
+
## Related
|
|
231
|
+
|
|
232
|
+
- Function INDEX: `packages/server/CLAUDE.md`
|
|
233
|
+
- Request pipeline: `packages/server/docs/request-pipeline.md`
|
|
234
|
+
- HTTP routes: `packages/server/docs/http-routes.md`
|
|
235
|
+
- Architecture: `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_AUTH.md`
|
|
236
|
+
- README: `packages/server/README.md`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -57,21 +57,24 @@
|
|
|
57
57
|
"test": "vitest run"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@luckystack/api": "^0.
|
|
61
|
-
"@luckystack/core": "^0.
|
|
60
|
+
"@luckystack/api": "^0.5.0",
|
|
61
|
+
"@luckystack/core": "^0.5.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"@prisma/client": "^6.19.0",
|
|
65
65
|
"socket.io": "^4.8.0",
|
|
66
|
-
"@luckystack/devkit": "^0.
|
|
67
|
-
"@luckystack/docs-ui": "^0.
|
|
68
|
-
"@luckystack/email": "^0.
|
|
69
|
-
"@luckystack/error-tracking": "^0.
|
|
70
|
-
"@luckystack/login": "^0.
|
|
71
|
-
"@luckystack/presence": "^0.
|
|
72
|
-
"@luckystack/sync": "^0.
|
|
66
|
+
"@luckystack/devkit": "^0.5.0",
|
|
67
|
+
"@luckystack/docs-ui": "^0.5.0",
|
|
68
|
+
"@luckystack/email": "^0.5.0",
|
|
69
|
+
"@luckystack/error-tracking": "^0.5.0",
|
|
70
|
+
"@luckystack/login": "^0.5.0",
|
|
71
|
+
"@luckystack/presence": "^0.5.0",
|
|
72
|
+
"@luckystack/sync": "^0.5.0"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
|
75
|
+
"@prisma/client": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
75
78
|
"@luckystack/devkit": {
|
|
76
79
|
"optional": true
|
|
77
80
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/argv.ts"],"sourcesContent":["//? Positional argv parser for the LuckyStack server boot. Replaces\n//? `LUCKYSTACK_BUNDLE` + `SERVER_PORT` env-var reads with a single shape:\n//?\n//? npm run server -- billing,vehicles 4001\n//?\n//? Arg 0 = comma-separated preset list (runtime maps merged across them).\n//? Arg 1 = listen port (numeric).\n//?\n//? `applyServerArgv()` is called by the side-effect module\n//? `@luckystack/server/parseArgv`, which consumers import as the FIRST line\n//? of their `server.ts` so the parsed port lands in `process.env.SERVER_PORT`\n//? before `config.ts` (top-level `backendUrl` const) is evaluated.\n\nexport interface ParsedServerArgv {\n bundles: string[];\n port: number | null;\n}\n\nlet parsedBundles: string[] = [];\nlet parsedPort: number | null = null;\nlet hasRun = false;\n\nconst PORT_PATTERN = /^\\d+$/;\n\nexport const parseServerArgv = (argv: string[]): ParsedServerArgv => {\n if (argv.length > 2) {\n throw new Error(\n `[luckystack:argv] unexpected positional argument(s): \"${argv.slice(2).join(' ')}\". ` +\n `Usage: npm run server -- <bundle[,bundle...]> [port]`,\n );\n }\n\n const bundles = argv[0] && argv[0].length > 0\n ? [...new Set(argv[0].split(',').map((s) => s.trim()).filter(Boolean))]\n : [];\n\n let port: number | null = null;\n const portArg = argv[1];\n if (portArg !== undefined) {\n if (!PORT_PATTERN.test(portArg)) {\n throw new Error(\n `[luckystack:argv] port argument must be numeric, got: \"${portArg}\". ` +\n `Usage: npm run server -- <bundle[,bundle...]> [port]`,\n );\n }\n port = Number.parseInt(portArg, 10);\n }\n\n return { bundles, port };\n};\n\nexport const applyServerArgv = (): void => {\n if (hasRun) return;\n hasRun = true;\n\n const result = parseServerArgv(process.argv.slice(2));\n parsedBundles = result.bundles;\n parsedPort = result.port;\n\n //? Writeback so the downstream env-readers (`core/env.ts` Zod schema,\n //? `core/bindAddress.ts` fallback, consumer `config.ts` backendUrl,\n //? `oauthProviders.ts` callback URL) see the resolved port without us\n //? having to refactor those four call sites. This is a deliberate\n //? implementation detail — argv is the public source of truth.\n if (parsedPort !== null) {\n process.env.SERVER_PORT = String(parsedPort);\n }\n};\n\nexport const getParsedBundles = (): string[] => parsedBundles;\nexport const getParsedPort = (): number | null => parsedPort;\n"],"mappings":";AAkBA,IAAI,gBAA0B,CAAC;AAC/B,IAAI,aAA4B;AAChC,IAAI,SAAS;AAEb,IAAM,eAAe;AAEd,IAAM,kBAAkB,CAAC,SAAqC;AACnE,MAAI,KAAK,SAAS,GAAG;AACnB,UAAM,IAAI;AAAA,MACR,yDAAyD,KAAK,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC;AAAA,IAElF;AAAA,EACF;AAEA,QAAM,UAAU,KAAK,CAAC,KAAK,KAAK,CAAC,EAAE,SAAS,IACxC,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC,EAAE,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO,CAAC,CAAC,IACpE,CAAC;AAEL,MAAI,OAAsB;AAC1B,QAAM,UAAU,KAAK,CAAC;AACtB,MAAI,YAAY,QAAW;AACzB,QAAI,CAAC,aAAa,KAAK,OAAO,GAAG;AAC/B,YAAM,IAAI;AAAA,QACR,0DAA0D,OAAO;AAAA,MAEnE;AAAA,IACF;AACA,WAAO,OAAO,SAAS,SAAS,EAAE;AAAA,EACpC;AAEA,SAAO,EAAE,SAAS,KAAK;AACzB;AAEO,IAAM,kBAAkB,MAAY;AACzC,MAAI,OAAQ;AACZ,WAAS;AAET,QAAM,SAAS,gBAAgB,QAAQ,KAAK,MAAM,CAAC,CAAC;AACpD,kBAAgB,OAAO;AACvB,eAAa,OAAO;AAOpB,MAAI,eAAe,MAAM;AACvB,YAAQ,IAAI,cAAc,OAAO,UAAU;AAAA,EAC7C;AACF;AAEO,IAAM,mBAAmB,MAAgB;AACzC,IAAM,gBAAgB,MAAqB;","names":[]}
|