@luckystack/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +83 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/index.d.ts +292 -0
- package/dist/index.js +1665 -0
- package/dist/index.js.map +1 -0
- package/dist/parseArgv.d.ts +2 -0
- package/dist/parseArgv.js +38 -0
- package/dist/parseArgv.js.map +1 -0
- package/docs/argv-parsing.md +256 -0
- package/docs/create-server.md +295 -0
- package/docs/http-routes.md +309 -0
- package/docs/request-pipeline.md +198 -0
- package/docs/runtime-maps.md +206 -0
- package/docs/security-defaults.md +236 -0
- package/package.json +79 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Runtime Maps Loader
|
|
2
|
+
|
|
3
|
+
> Deep specs. Bron: `packages/server/src/runtimeMapsLoader.ts`. Bijgewerkt: 2026-05-20.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
A `RuntimeMapsProvider` (defined in `@luckystack/core`) is the runtime source of truth for which `apis`, `syncs`, and `functions` exist in the running process. Without one, every `/api/*` and `/sync/*` request silently resolves to `notFound`.
|
|
8
|
+
|
|
9
|
+
`@luckystack/server` ships two helpers so consumers no longer need to hand-roll their own `server/prod/runtimeMaps.ts`:
|
|
10
|
+
|
|
11
|
+
- `createProdRuntimeMapsProvider(options)` — builds and returns the provider without registering.
|
|
12
|
+
- `registerProdRuntimeMapsProvider(options)` — convenience wrapper: builds AND calls `registerRuntimeMapsProvider(provider)`. Most consumers want this.
|
|
13
|
+
|
|
14
|
+
In production, the provider loads generated maps via the consumer-supplied `loadGenerated(preset)` callback. In dev it transparently delegates to `@luckystack/devkit`'s in-memory discovery (`devApis` / `devSyncs` / `devFunctions`), which is populated by the devkit watchers.
|
|
15
|
+
|
|
16
|
+
Why does the consumer supply `loadGenerated`? Dynamic-import path resolution in ESM is module-scoped — the framework cannot resolve a relative path on the consumer's behalf. Passing a function that calls `import(\`./prod/generatedApis.${preset}\`)` lets the consumer-side module own resolution.
|
|
17
|
+
|
|
18
|
+
Preset list resolution (in `resolvePresets`):
|
|
19
|
+
|
|
20
|
+
1. `options.preset` if it is a non-empty string -> `[preset]`.
|
|
21
|
+
2. `options.preset` if it is a non-empty array -> dedup via `Array.from(new Set(...))`.
|
|
22
|
+
3. `getParsedBundles()` from `argv.ts` when non-empty.
|
|
23
|
+
4. Final fallback: `['default']`.
|
|
24
|
+
|
|
25
|
+
Multiple presets are loaded in parallel, normalized to `{ apisObject, syncObject, functionsObject }`, then shallow-merged into a single view. Key collisions across presets throw at boot — services must own exactly one preset (see `docs/ARCHITECTURE_PACKAGING.md` §10).
|
|
26
|
+
|
|
27
|
+
## API Reference
|
|
28
|
+
|
|
29
|
+
### `createProdRuntimeMapsProvider(options: ProdRuntimeMapsLoaderOptions): RuntimeMapsProvider`
|
|
30
|
+
|
|
31
|
+
**Signature:**
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
export interface ProdRuntimeMapsLoaderOptions {
|
|
35
|
+
loadGenerated: (preset: string) => Promise<unknown>;
|
|
36
|
+
preset?: string | string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const createProdRuntimeMapsProvider = (
|
|
40
|
+
options: ProdRuntimeMapsLoaderOptions,
|
|
41
|
+
): RuntimeMapsProvider;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Parameters:**
|
|
45
|
+
|
|
46
|
+
| Field | Type | Default | Purpose |
|
|
47
|
+
| --- | --- | --- | --- |
|
|
48
|
+
| `loadGenerated` | `(preset: string) => Promise<unknown>` | required | Dynamic-import callback that resolves the generated maps module for a given preset. The resolved module must have shape `{ apis, syncs, functions }` (what `scripts/generateServerRequests.ts` emits). Called once per preset per process lifetime; the result is cached. |
|
|
49
|
+
| `preset` | `string \| string[]` | argv -> `['default']` | Override for the preset list. String -> single-entry array; array -> deduplicated. Skips the argv lookup. |
|
|
50
|
+
|
|
51
|
+
**Returns:** a `RuntimeMapsProvider`:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
interface RuntimeMapsProvider {
|
|
55
|
+
getRuntimeApiMaps: () => Promise<{ apisObject: Record<string, unknown>; functionsObject: Record<string, unknown> }>;
|
|
56
|
+
getRuntimeSyncMaps: () => Promise<{ syncObject: Record<string, unknown>; functionsObject: Record<string, unknown> }>;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Behavior:**
|
|
61
|
+
|
|
62
|
+
- Builds two getters (`getRuntimeApiMaps`, `getRuntimeSyncMaps`) that switch on `process.env.NODE_ENV === 'production'`:
|
|
63
|
+
- **Production:** lazy-loads + caches the prod maps via `loadProdRuntimeMaps`. Subsequent calls await the same `prodMapsPromise`.
|
|
64
|
+
- **Dev:** lazy-imports `@luckystack/devkit` and returns `{ devApis, devSyncs, devFunctions }`. Devkit is excluded from production bundles so this dynamic import is never reached when `NODE_ENV === 'production'`.
|
|
65
|
+
- `loadProdRuntimeMaps`:
|
|
66
|
+
1. Resolve presets via `resolvePresets(options)`.
|
|
67
|
+
2. Concurrently `loadGenerated(preset)` for each preset; failures resolve to `null` (caught with `.catch(() => null)`).
|
|
68
|
+
3. For each loaded module, run `normalizeGeneratedModule` (defends against missing or malformed export shape: anything other than an object becomes `{}`).
|
|
69
|
+
4. Merge `apis`, `syncs`, `functions` into the combined view with `mergeInto`, tracking origin per key in three `Map<string, string>`s.
|
|
70
|
+
5. Skipped presets emit `console.warn('[luckystack:runtimeMaps] preset "<name>" failed to load — skipping. ...')`.
|
|
71
|
+
6. If no presets loaded successfully, emit `console.warn('[luckystack:runtimeMaps] no presets resolved (tried: ...). Every api/sync request will return notFound until at least one generated module loads.')` and return empty maps.
|
|
72
|
+
- The dev branch caches the devkit module promise so repeated calls share one resolved module.
|
|
73
|
+
|
|
74
|
+
**Errors / Edge cases:**
|
|
75
|
+
|
|
76
|
+
- Key collision across presets throws synchronously inside `loadProdRuntimeMaps`:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
[luckystack:runtimeMaps] <kind> key collision: "<key>" present in both preset "<previous>" and preset "<current>". Services must belong to exactly one preset (see docs/ARCHITECTURE_PACKAGING.md §10).
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- Failed `import()` (e.g. preset module not built) is swallowed and logged as a warning; the provider continues with whatever did load.
|
|
83
|
+
- A preset module whose default export is not an object normalizes to `{}` for all three maps — no throw, just empty.
|
|
84
|
+
- The dev branch dynamic-imports `'@luckystack/devkit'` even when only one of the two getters is invoked; the module promise is cached.
|
|
85
|
+
|
|
86
|
+
**Example:**
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { createProdRuntimeMapsProvider, registerRuntimeMapsProvider } from '@luckystack/server';
|
|
90
|
+
|
|
91
|
+
const provider = createProdRuntimeMapsProvider({
|
|
92
|
+
loadGenerated: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
93
|
+
preset: 'billing',
|
|
94
|
+
});
|
|
95
|
+
registerRuntimeMapsProvider(provider);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### `registerProdRuntimeMapsProvider(options: ProdRuntimeMapsLoaderOptions): RuntimeMapsProvider`
|
|
101
|
+
|
|
102
|
+
**Signature:**
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
export const registerProdRuntimeMapsProvider = (
|
|
106
|
+
options: ProdRuntimeMapsLoaderOptions,
|
|
107
|
+
): RuntimeMapsProvider;
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Parameters:** identical to `createProdRuntimeMapsProvider`.
|
|
111
|
+
|
|
112
|
+
**Returns:** the same `RuntimeMapsProvider` instance that was registered.
|
|
113
|
+
|
|
114
|
+
**Behavior:**
|
|
115
|
+
|
|
116
|
+
- Internally:
|
|
117
|
+
```typescript
|
|
118
|
+
const provider = createProdRuntimeMapsProvider(options);
|
|
119
|
+
registerRuntimeMapsProvider(provider);
|
|
120
|
+
return provider;
|
|
121
|
+
```
|
|
122
|
+
- Calling twice replaces the active provider (last-write-wins) — `registerRuntimeMapsProvider` is the registry function.
|
|
123
|
+
|
|
124
|
+
**Errors / Edge cases:** same as `createProdRuntimeMapsProvider`. `registerRuntimeMapsProvider` itself does not throw on duplicate calls.
|
|
125
|
+
|
|
126
|
+
**Example — direct use:**
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { registerProdRuntimeMapsProvider } from '@luckystack/server';
|
|
130
|
+
|
|
131
|
+
registerProdRuntimeMapsProvider({
|
|
132
|
+
loadGenerated: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Example — via `createLuckyStackServer.loadGeneratedMaps`:** when the option is supplied, `createLuckyStackServer` calls `registerProdRuntimeMapsProvider` for you before `verifyBootstrap` runs.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { createLuckyStackServer } from '@luckystack/server';
|
|
140
|
+
|
|
141
|
+
const server = await createLuckyStackServer({
|
|
142
|
+
serveFile,
|
|
143
|
+
serveFavicon,
|
|
144
|
+
loadGeneratedMaps: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
145
|
+
runtimeMapsPreset: ['billing', 'vehicles'],
|
|
146
|
+
});
|
|
147
|
+
await server.listen();
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Expected module shape
|
|
151
|
+
|
|
152
|
+
`loadGenerated(preset)` must resolve to a module that has these named exports:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
export const apis: Record<string, ApiRouteEntry>;
|
|
156
|
+
export const syncs: Record<string, SyncRouteEntry>;
|
|
157
|
+
export const functions: Record<string, FunctionEntry>;
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This matches what `scripts/generateServerRequests.ts` emits. Missing exports default to `{}` — useful when a preset has no syncs or functions.
|
|
161
|
+
|
|
162
|
+
## Merge semantics
|
|
163
|
+
|
|
164
|
+
`mergeInto(target, source, kind, fromPreset, keyOrigin)`:
|
|
165
|
+
|
|
166
|
+
- Iterate every key in `source`.
|
|
167
|
+
- If `keyOrigin` already records a different preset for that key -> throw the collision error.
|
|
168
|
+
- Otherwise record the origin and shallow-assign `target[key] = source[key]`.
|
|
169
|
+
|
|
170
|
+
Three independent origin maps are used so an api named `users/get` and a sync named `users/get` do not collide.
|
|
171
|
+
|
|
172
|
+
## Failure modes
|
|
173
|
+
|
|
174
|
+
| Situation | Behavior |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| `loadGenerated(preset)` rejects | Warn (`preset "<name>" failed to load — skipping`); continue with other presets. |
|
|
177
|
+
| All presets failed | Warn (`no presets resolved (tried: ...)`); return empty maps; every request returns `notFound`. |
|
|
178
|
+
| Preset module has wrong shape | Normalize to `{}`. Same effect: requests for those routes return `notFound`. |
|
|
179
|
+
| Key collision across presets | Throw at boot — services must own exactly one preset. |
|
|
180
|
+
| Provider is never registered at all | `verifyBootstrap` hard-fails in production, loud-warns in dev. |
|
|
181
|
+
|
|
182
|
+
## Dev vs prod branch
|
|
183
|
+
|
|
184
|
+
| Environment | Source of maps |
|
|
185
|
+
| --- | --- |
|
|
186
|
+
| `NODE_ENV === 'production'` | `loadGenerated(preset)` for each resolved preset; cached `prodMapsPromise`. |
|
|
187
|
+
| Anything else | Dynamic import of `'@luckystack/devkit'` -> `{ devApis, devSyncs, devFunctions }`. Devkit watchers keep these maps live. |
|
|
188
|
+
|
|
189
|
+
The dynamic import to `'@luckystack/devkit'` is intentionally written as `import('@luckystack/devkit')` and not statically referenced, so bundlers tree-shake it out of production builds.
|
|
190
|
+
|
|
191
|
+
## Config keys
|
|
192
|
+
|
|
193
|
+
| Source | Key | Effect |
|
|
194
|
+
| --- | --- | --- |
|
|
195
|
+
| env | `NODE_ENV` | Switches dev (devkit) vs prod (`loadGenerated`) branch. |
|
|
196
|
+
| argv | `<bundles>` | Preset list when `options.preset` is omitted. |
|
|
197
|
+
| options | `options.preset` | Overrides argv-derived preset list. |
|
|
198
|
+
| options | `options.loadGenerated` | Required prod loader callback. |
|
|
199
|
+
|
|
200
|
+
## Related
|
|
201
|
+
|
|
202
|
+
- Function INDEX: `packages/server/CLAUDE.md`
|
|
203
|
+
- Argv parsing: `packages/server/docs/argv-parsing.md`
|
|
204
|
+
- Create server: `packages/server/docs/create-server.md`
|
|
205
|
+
- Architecture: `docs/ARCHITECTURE_PACKAGING.md` (§10 preset bundles + multi-service builds)
|
|
206
|
+
- README: `packages/server/README.md`
|
|
@@ -0,0 +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`
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luckystack/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "One-call server bootstrap for LuckyStack: HTTP + Socket.io + framework routes (api, sync, _health, /auth, uploads) wired together. Consumer's server.ts shrinks to ~20 lines.",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Mathijs van Melick <mathijsvanmelick3@gmail.com>",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ItsLucky23/LuckyStack-v2.git",
|
|
15
|
+
"directory": "packages/server"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/ItsLucky23/LuckyStack-v2/tree/master/packages/server#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/ItsLucky23/LuckyStack-v2/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"luckystack",
|
|
23
|
+
"server",
|
|
24
|
+
"bootstrap",
|
|
25
|
+
"socket.io",
|
|
26
|
+
"http",
|
|
27
|
+
"framework",
|
|
28
|
+
"fullstack"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.0.0"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"import": "./dist/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./parseArgv": {
|
|
41
|
+
"types": "./dist/parseArgv.d.ts",
|
|
42
|
+
"import": "./dist/parseArgv.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"CLAUDE.md",
|
|
49
|
+
"docs",
|
|
50
|
+
"LICENSE",
|
|
51
|
+
"CHANGELOG.md"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsup",
|
|
55
|
+
"build:watch": "tsup --watch",
|
|
56
|
+
"test": "vitest run"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@luckystack/api": "^0.1.0",
|
|
60
|
+
"@luckystack/core": "^0.1.0",
|
|
61
|
+
"@luckystack/login": "^0.1.0",
|
|
62
|
+
"@luckystack/presence": "^0.1.0",
|
|
63
|
+
"@luckystack/sync": "^0.1.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"@prisma/client": "^6.19.0",
|
|
67
|
+
"socket.io": "^4.8.0",
|
|
68
|
+
"@luckystack/devkit": "^0.1.0",
|
|
69
|
+
"@luckystack/docs-ui": "^0.1.0",
|
|
70
|
+
"@luckystack/email": "^0.1.0",
|
|
71
|
+
"@luckystack/error-tracking": "^0.1.0"
|
|
72
|
+
},
|
|
73
|
+
"peerDependenciesMeta": {
|
|
74
|
+
"@luckystack/devkit": { "optional": true },
|
|
75
|
+
"@luckystack/docs-ui": { "optional": true },
|
|
76
|
+
"@luckystack/email": { "optional": true },
|
|
77
|
+
"@luckystack/error-tracking": { "optional": true }
|
|
78
|
+
}
|
|
79
|
+
}
|