@mandujs/core 0.20.10 → 0.22.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/README.md +2 -1
- package/package.json +28 -3
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +495 -37
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1671 -80
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +19 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/config/validate.ts +1 -0
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +679 -23
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +247 -189
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure HTTP Headers Middleware
|
|
3
|
+
*
|
|
4
|
+
* A Helmet-equivalent bundle shipping OWASP-recommended defaults for:
|
|
5
|
+
* - Content-Security-Policy (CSP) — see `./csp.ts`
|
|
6
|
+
* - Strict-Transport-Security (HSTS)
|
|
7
|
+
* - X-Frame-Options (legacy; superseded by CSP `frame-ancestors`)
|
|
8
|
+
* - X-Content-Type-Options
|
|
9
|
+
* - Referrer-Policy
|
|
10
|
+
* - Permissions-Policy
|
|
11
|
+
* - X-XSS-Protection (legacy; explicitly `"0"` on modern clients)
|
|
12
|
+
*
|
|
13
|
+
* Strategy: we implement `MiddlewarePlugin` rather than the bare
|
|
14
|
+
* `(ctx) => Response | void` signature so we can mutate the outgoing Response
|
|
15
|
+
* via `afterHandle`. This mirrors `cors.ts` and means we do NOT need any
|
|
16
|
+
* framework modifications, a new "pending headers" buffer in the context, or
|
|
17
|
+
* a custom wrapper helper that callers must remember to invoke.
|
|
18
|
+
*
|
|
19
|
+
* Ordering vs. session/cookies:
|
|
20
|
+
* Unlike session middleware, which must commit BEFORE the response is
|
|
21
|
+
* built (DX-3 + Phase 2.3), header-setting middleware can run AFTER the
|
|
22
|
+
* response is produced — we use `afterHandle` which receives the fully-
|
|
23
|
+
* constructed Response and returns a replacement. No ordering hazard with
|
|
24
|
+
* cookies/session state, because we never touch `ctx.cookies`.
|
|
25
|
+
*
|
|
26
|
+
* CSP nonce plumbing:
|
|
27
|
+
* When `csp.nonce === true`, we compute a fresh nonce in `beforeHandle`
|
|
28
|
+
* and stash it on the context under the key `"csp-nonce"`. SSR handlers
|
|
29
|
+
* that render inline `<script>` tags can read it with
|
|
30
|
+
* `ctx.get<string>("csp-nonce")`. The same nonce is interpolated into the
|
|
31
|
+
* CSP header in `afterHandle`, guaranteeing the tag and the header agree.
|
|
32
|
+
*
|
|
33
|
+
* Auto-injection of the nonce into `renderToStream`'s hydration script
|
|
34
|
+
* tags is intentionally out of scope for this middleware — see the
|
|
35
|
+
* Phase 6.2 follow-up item in `CLAUDE.md`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { secure } from "@mandujs/core/middleware";
|
|
40
|
+
*
|
|
41
|
+
* export default Mandu.filling()
|
|
42
|
+
* .use(secure()) // all defaults
|
|
43
|
+
* .get((ctx) => ctx.ok({ hello: "world" }));
|
|
44
|
+
*
|
|
45
|
+
* // With CSP nonce:
|
|
46
|
+
* export default Mandu.filling()
|
|
47
|
+
* .use(secure({ csp: { nonce: true } }))
|
|
48
|
+
* .get((ctx) => {
|
|
49
|
+
* const nonce = ctx.get<string>("csp-nonce");
|
|
50
|
+
* // render inline script with nonce={nonce}
|
|
51
|
+
* return ctx.ok({ ok: true });
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
import type { MiddlewarePlugin } from "../../filling/filling";
|
|
56
|
+
import type { ManduContext } from "../../filling/context";
|
|
57
|
+
import { buildCsp, type CspOptions } from "./csp";
|
|
58
|
+
|
|
59
|
+
export { buildCsp, DEFAULT_CSP_DIRECTIVES } from "./csp";
|
|
60
|
+
export type { CspOptions, BuiltCsp } from "./csp";
|
|
61
|
+
|
|
62
|
+
// ========== Types ==========
|
|
63
|
+
|
|
64
|
+
export type ReferrerPolicyValue =
|
|
65
|
+
| "no-referrer"
|
|
66
|
+
| "no-referrer-when-downgrade"
|
|
67
|
+
| "origin"
|
|
68
|
+
| "origin-when-cross-origin"
|
|
69
|
+
| "same-origin"
|
|
70
|
+
| "strict-origin"
|
|
71
|
+
| "strict-origin-when-cross-origin"
|
|
72
|
+
| "unsafe-url";
|
|
73
|
+
|
|
74
|
+
export interface HstsOptions {
|
|
75
|
+
/** max-age in seconds. Default: 15552000 (180 days). */
|
|
76
|
+
maxAge?: number;
|
|
77
|
+
/** includeSubDomains directive. Default: true. */
|
|
78
|
+
includeSubDomains?: boolean;
|
|
79
|
+
/** preload directive. Default: false — opt-in; implies submitting to the preload list. */
|
|
80
|
+
preload?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SecureMiddlewareOptions {
|
|
84
|
+
/** Content-Security-Policy options, or `false` to disable. */
|
|
85
|
+
csp?: CspOptions | false;
|
|
86
|
+
/** Strict-Transport-Security options, or `false` to disable. */
|
|
87
|
+
hsts?: HstsOptions | false;
|
|
88
|
+
/** X-Frame-Options. Default: `"DENY"`. `false` to disable. */
|
|
89
|
+
frameOptions?: "DENY" | "SAMEORIGIN" | false;
|
|
90
|
+
/** X-Content-Type-Options: nosniff. Default: true. */
|
|
91
|
+
noSniff?: boolean;
|
|
92
|
+
/** Referrer-Policy. Default: `"strict-origin-when-cross-origin"`. */
|
|
93
|
+
referrerPolicy?: ReferrerPolicyValue | false;
|
|
94
|
+
/**
|
|
95
|
+
* Permissions-Policy map. Each entry becomes `feature=(allowlist)`.
|
|
96
|
+
* Use an empty array to deny a feature entirely (`feature=()`).
|
|
97
|
+
* `false` disables the header.
|
|
98
|
+
*/
|
|
99
|
+
permissionsPolicy?: Record<string, string[]> | false;
|
|
100
|
+
/**
|
|
101
|
+
* X-XSS-Protection. Default: `"0"` — modern browsers should use CSP; the
|
|
102
|
+
* legacy auditor has known bypasses and is best disabled. `false` omits.
|
|
103
|
+
*/
|
|
104
|
+
xssProtection?: "0" | "1" | "1; mode=block" | false;
|
|
105
|
+
/**
|
|
106
|
+
* Extra headers to set verbatim. Keys are set as-given (casing preserved
|
|
107
|
+
* by `Headers` normalization rules). Values overwrite any existing header.
|
|
108
|
+
*/
|
|
109
|
+
extra?: Record<string, string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ========== Defaults ==========
|
|
113
|
+
|
|
114
|
+
const DEFAULT_HSTS: Required<HstsOptions> = Object.freeze({
|
|
115
|
+
maxAge: 15552000, // 180 days — matches Chrome's preload minimum
|
|
116
|
+
includeSubDomains: true,
|
|
117
|
+
preload: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const DEFAULT_REFERRER_POLICY: ReferrerPolicyValue = "strict-origin-when-cross-origin";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* OWASP-recommended deny-by-default for commonly abused browser capabilities.
|
|
124
|
+
*
|
|
125
|
+
* - `camera` / `microphone`: prevent drive-by media capture; app pages that
|
|
126
|
+
* need these must opt in explicitly via override.
|
|
127
|
+
* - `geolocation`: prevents third-party scripts from geo-tagging users.
|
|
128
|
+
* - `payment`: blocks Payment Request API unless you explicitly own it.
|
|
129
|
+
* - `usb`: blocks WebUSB (e.g. hardware key attacks).
|
|
130
|
+
* - `interest-cohort`: opts out of FLoC/Topics tracking. Still widely
|
|
131
|
+
* respected; cheap insurance.
|
|
132
|
+
*/
|
|
133
|
+
const DEFAULT_PERMISSIONS_POLICY: Record<string, string[]> = Object.freeze({
|
|
134
|
+
camera: [],
|
|
135
|
+
microphone: [],
|
|
136
|
+
geolocation: [],
|
|
137
|
+
payment: [],
|
|
138
|
+
usb: [],
|
|
139
|
+
"interest-cohort": [],
|
|
140
|
+
}) as Record<string, string[]>;
|
|
141
|
+
|
|
142
|
+
const CSP_NONCE_KEY = "csp-nonce";
|
|
143
|
+
|
|
144
|
+
// ========== Public API ==========
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the secure headers middleware. The options argument is consumed
|
|
148
|
+
* eagerly at construction time — subsequent mutations to the passed object
|
|
149
|
+
* do not affect the already-installed middleware.
|
|
150
|
+
*
|
|
151
|
+
* Returns a `MiddlewarePlugin` with:
|
|
152
|
+
* - `beforeHandle`: generates the per-request CSP nonce (if enabled) and
|
|
153
|
+
* stashes it on the context for handler use.
|
|
154
|
+
* - `afterHandle`: computes the final headers bundle and returns a new
|
|
155
|
+
* Response with the headers applied. Existing headers from the handler
|
|
156
|
+
* (e.g. `Content-Type`) are preserved.
|
|
157
|
+
*/
|
|
158
|
+
export function secure(options: SecureMiddlewareOptions = {}): MiddlewarePlugin {
|
|
159
|
+
// Normalize / snapshot options up front so we don't re-read user input on
|
|
160
|
+
// every request (protects against surprise mutation mid-session).
|
|
161
|
+
const cfg = normalizeOptions(options);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
beforeHandle: async (ctx: ManduContext): Promise<void> => {
|
|
165
|
+
// If CSP is enabled AND nonce is requested, compute the nonce here so
|
|
166
|
+
// the handler can read it before producing the response. We build the
|
|
167
|
+
// full CSP string in `afterHandle` (cheap) using the same nonce so
|
|
168
|
+
// the header and any handler-rendered <script nonce={...}> agree.
|
|
169
|
+
if (cfg.csp && cfg.csp.nonce === true) {
|
|
170
|
+
// buildCsp will synthesize a fresh nonce; we capture it here and
|
|
171
|
+
// pin it for the afterHandle pass via a per-request override.
|
|
172
|
+
const built = buildCsp({ ...cfg.csp, nonce: true });
|
|
173
|
+
if (built.nonce) {
|
|
174
|
+
ctx.set<string>(CSP_NONCE_KEY, built.nonce);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
afterHandle: async (
|
|
180
|
+
ctx: ManduContext,
|
|
181
|
+
response: Response
|
|
182
|
+
): Promise<Response> => {
|
|
183
|
+
const headers = new Headers(response.headers);
|
|
184
|
+
applySecureHeaders(ctx, headers, cfg);
|
|
185
|
+
|
|
186
|
+
// Only re-wrap if we actually added/changed something. In practice the
|
|
187
|
+
// headers map is always non-empty (we always set at least one header
|
|
188
|
+
// when options are default), but this keeps us honest about
|
|
189
|
+
// Response-body identity and matches `cors.ts`'s pattern.
|
|
190
|
+
return new Response(response.body, {
|
|
191
|
+
status: response.status,
|
|
192
|
+
statusText: response.statusText,
|
|
193
|
+
headers,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Manual escape hatch: apply secure headers to an arbitrary Response using
|
|
201
|
+
* the options shape above.
|
|
202
|
+
*
|
|
203
|
+
* Prefer `.use(secure(...))` — this helper exists for callers outside the
|
|
204
|
+
* filling pipeline (e.g. custom error responders, static file handlers).
|
|
205
|
+
* Since no context is available, CSP nonce mode falls back to the one-shot
|
|
206
|
+
* nonce generated inside `buildCsp` — the caller is responsible for wiring
|
|
207
|
+
* that nonce into whatever they render.
|
|
208
|
+
*/
|
|
209
|
+
export function applySecureHeadersToResponse(
|
|
210
|
+
response: Response,
|
|
211
|
+
options: SecureMiddlewareOptions = {}
|
|
212
|
+
): { response: Response; nonce?: string } {
|
|
213
|
+
const cfg = normalizeOptions(options);
|
|
214
|
+
const headers = new Headers(response.headers);
|
|
215
|
+
|
|
216
|
+
// Derive a fresh nonce for this standalone call (no context to cache it on).
|
|
217
|
+
let emittedNonce: string | undefined;
|
|
218
|
+
if (cfg.csp && cfg.csp.nonce === true) {
|
|
219
|
+
const built = buildCsp({ ...cfg.csp, nonce: true });
|
|
220
|
+
emittedNonce = built.nonce;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Pseudo-context shim so applySecureHeaders can read the nonce consistently.
|
|
224
|
+
const shim = {
|
|
225
|
+
request: response as unknown as Request, // only used for URL scheme check; standalone callers skip HSTS logic path below via hsts=false typically
|
|
226
|
+
get: <T>(_key: string): T | undefined => emittedNonce as T | undefined,
|
|
227
|
+
};
|
|
228
|
+
applySecureHeaders(shim as unknown as ManduContext, headers, {
|
|
229
|
+
...cfg,
|
|
230
|
+
// If caller didn't explicitly disable HSTS, keep it — but the scheme
|
|
231
|
+
// probe will simply no-op on a non-Request shim. Callers who want HSTS
|
|
232
|
+
// on standalone responses must ensure `response.url` carries an
|
|
233
|
+
// `https:` URL, or they should pass a pre-built Request with
|
|
234
|
+
// `x-forwarded-proto: https`.
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
response: new Response(response.body, {
|
|
239
|
+
status: response.status,
|
|
240
|
+
statusText: response.statusText,
|
|
241
|
+
headers,
|
|
242
|
+
}),
|
|
243
|
+
nonce: emittedNonce,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ========== Internal: normalization ==========
|
|
248
|
+
|
|
249
|
+
interface NormalizedOptions {
|
|
250
|
+
csp: CspOptions | null;
|
|
251
|
+
hsts: Required<HstsOptions> | null;
|
|
252
|
+
frameOptions: "DENY" | "SAMEORIGIN" | null;
|
|
253
|
+
noSniff: boolean;
|
|
254
|
+
referrerPolicy: ReferrerPolicyValue | null;
|
|
255
|
+
permissionsPolicy: Record<string, string[]> | null;
|
|
256
|
+
xssProtection: "0" | "1" | "1; mode=block" | null;
|
|
257
|
+
extra: Record<string, string>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeOptions(options: SecureMiddlewareOptions): NormalizedOptions {
|
|
261
|
+
return {
|
|
262
|
+
csp: options.csp === false ? null : options.csp ?? {},
|
|
263
|
+
hsts:
|
|
264
|
+
options.hsts === false
|
|
265
|
+
? null
|
|
266
|
+
: { ...DEFAULT_HSTS, ...(options.hsts ?? {}) },
|
|
267
|
+
frameOptions:
|
|
268
|
+
options.frameOptions === false
|
|
269
|
+
? null
|
|
270
|
+
: options.frameOptions ?? "DENY",
|
|
271
|
+
noSniff: options.noSniff !== false,
|
|
272
|
+
referrerPolicy:
|
|
273
|
+
options.referrerPolicy === false
|
|
274
|
+
? null
|
|
275
|
+
: options.referrerPolicy ?? DEFAULT_REFERRER_POLICY,
|
|
276
|
+
permissionsPolicy:
|
|
277
|
+
options.permissionsPolicy === false
|
|
278
|
+
? null
|
|
279
|
+
: options.permissionsPolicy ?? { ...DEFAULT_PERMISSIONS_POLICY },
|
|
280
|
+
xssProtection:
|
|
281
|
+
options.xssProtection === false ? null : options.xssProtection ?? "0",
|
|
282
|
+
extra: options.extra ?? {},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ========== Internal: header application ==========
|
|
287
|
+
|
|
288
|
+
function applySecureHeaders(
|
|
289
|
+
ctx: ManduContext,
|
|
290
|
+
headers: Headers,
|
|
291
|
+
cfg: NormalizedOptions
|
|
292
|
+
): void {
|
|
293
|
+
// --- CSP ---
|
|
294
|
+
if (cfg.csp) {
|
|
295
|
+
// If a nonce was pre-computed in beforeHandle, reuse it to ensure the
|
|
296
|
+
// handler's nonce=… values match what we emit in the header.
|
|
297
|
+
const pinnedNonce = ctx.get<string>(CSP_NONCE_KEY);
|
|
298
|
+
const effective =
|
|
299
|
+
pinnedNonce && cfg.csp.nonce === true
|
|
300
|
+
? { ...cfg.csp, nonce: pinnedNonce }
|
|
301
|
+
: cfg.csp;
|
|
302
|
+
|
|
303
|
+
const built = buildCsp(effective);
|
|
304
|
+
headers.set(built.name, built.header);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- HSTS (only when the request is already HTTPS) ---
|
|
308
|
+
//
|
|
309
|
+
// RFC 6797 §7.2: UAs MUST ignore STS on insecure transport, but we also
|
|
310
|
+
// suppress it server-side to avoid leaking the policy across a plaintext
|
|
311
|
+
// channel (where an active MITM could strip it for first-visit users
|
|
312
|
+
// anyway — the "TOFU" problem HSTS is designed to reduce).
|
|
313
|
+
if (cfg.hsts && isHttps(ctx.request)) {
|
|
314
|
+
const parts = [`max-age=${Math.floor(cfg.hsts.maxAge)}`];
|
|
315
|
+
if (cfg.hsts.includeSubDomains) parts.push("includeSubDomains");
|
|
316
|
+
if (cfg.hsts.preload) parts.push("preload");
|
|
317
|
+
headers.set("Strict-Transport-Security", parts.join("; "));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- X-Frame-Options ---
|
|
321
|
+
if (cfg.frameOptions) {
|
|
322
|
+
headers.set("X-Frame-Options", cfg.frameOptions);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- X-Content-Type-Options ---
|
|
326
|
+
if (cfg.noSniff) {
|
|
327
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- Referrer-Policy ---
|
|
331
|
+
if (cfg.referrerPolicy) {
|
|
332
|
+
headers.set("Referrer-Policy", cfg.referrerPolicy);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Permissions-Policy ---
|
|
336
|
+
if (cfg.permissionsPolicy) {
|
|
337
|
+
const pp = buildPermissionsPolicy(cfg.permissionsPolicy);
|
|
338
|
+
if (pp.length > 0) {
|
|
339
|
+
headers.set("Permissions-Policy", pp);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- X-XSS-Protection ---
|
|
344
|
+
if (cfg.xssProtection !== null) {
|
|
345
|
+
headers.set("X-XSS-Protection", cfg.xssProtection);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Arbitrary extras (caller overrides always win) ---
|
|
349
|
+
for (const [k, v] of Object.entries(cfg.extra)) {
|
|
350
|
+
headers.set(k, v);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Build a Permissions-Policy header value.
|
|
356
|
+
*
|
|
357
|
+
* Grammar (simplified): `feature=(allowlist) , feature=(allowlist)`
|
|
358
|
+
* - Bare tokens like `self` go inside the parens as-is.
|
|
359
|
+
* - Origins (URLs) must be wrapped in double quotes per spec.
|
|
360
|
+
* - Empty allowlist `()` denies the feature entirely.
|
|
361
|
+
*
|
|
362
|
+
* We accept the caller's array verbatim; they are responsible for quoting
|
|
363
|
+
* their URL-shaped entries. We do wrap origins that look URL-ish (contain
|
|
364
|
+
* `://`) when they are unquoted, because that's by far the most common
|
|
365
|
+
* mistake and the cost of the heuristic is tiny.
|
|
366
|
+
*/
|
|
367
|
+
function buildPermissionsPolicy(map: Record<string, string[]>): string {
|
|
368
|
+
const entries: string[] = [];
|
|
369
|
+
for (const [feature, allowlist] of Object.entries(map)) {
|
|
370
|
+
if (!Array.isArray(allowlist)) continue;
|
|
371
|
+
const items = allowlist.map(normalizePermissionsItem).join(" ");
|
|
372
|
+
entries.push(`${feature}=(${items})`);
|
|
373
|
+
}
|
|
374
|
+
return entries.join(", ");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function normalizePermissionsItem(raw: string): string {
|
|
378
|
+
if (raw === "self" || raw === "*") return raw;
|
|
379
|
+
if (raw.startsWith('"') && raw.endsWith('"')) return raw;
|
|
380
|
+
if (raw.includes("://")) return `"${raw}"`;
|
|
381
|
+
return raw;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Determine whether the inbound request is HTTPS.
|
|
386
|
+
*
|
|
387
|
+
* Recognizes:
|
|
388
|
+
* 1. Direct `https:` scheme in `request.url`
|
|
389
|
+
* 2. `X-Forwarded-Proto: https` (de-facto reverse-proxy header)
|
|
390
|
+
* 3. `Forwarded: proto=https` (RFC 7239)
|
|
391
|
+
*
|
|
392
|
+
* We intentionally don't trust these headers on direct (non-proxied)
|
|
393
|
+
* connections — but since the caller is the one opting into HSTS, they're
|
|
394
|
+
* also responsible for running behind a reverse proxy that sanitizes
|
|
395
|
+
* client-supplied `Forwarded` / `X-Forwarded-*` headers. This matches
|
|
396
|
+
* Helmet's and express's documented behavior.
|
|
397
|
+
*/
|
|
398
|
+
function isHttps(request: Request): boolean {
|
|
399
|
+
try {
|
|
400
|
+
if (request.url.startsWith("https:")) return true;
|
|
401
|
+
} catch {
|
|
402
|
+
// Some shims may throw on `.url`; fall through to header checks.
|
|
403
|
+
}
|
|
404
|
+
const xfp = safeGetHeader(request, "x-forwarded-proto");
|
|
405
|
+
if (xfp && xfp.split(",")[0]!.trim().toLowerCase() === "https") return true;
|
|
406
|
+
const fwd = safeGetHeader(request, "forwarded");
|
|
407
|
+
if (fwd && /\bproto=https\b/i.test(fwd)) return true;
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function safeGetHeader(request: Request, name: string): string | null {
|
|
412
|
+
try {
|
|
413
|
+
return request.headers?.get(name) ?? null;
|
|
414
|
+
} catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Middleware Plugin
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the existing `SessionStorage` contract
|
|
5
|
+
* (`packages/core/src/filling/session.ts`). Attaches an already-fetched
|
|
6
|
+
* {@link Session} to the request context so handlers can do:
|
|
7
|
+
*
|
|
8
|
+
* const session = ctx.get<Session>("session");
|
|
9
|
+
* session.set("userId", user.id);
|
|
10
|
+
* await saveSession(ctx);
|
|
11
|
+
*
|
|
12
|
+
* ...instead of threading storage + cookies through manually.
|
|
13
|
+
*
|
|
14
|
+
* Commit is **caller-driven**: the middleware pipeline has no after-response
|
|
15
|
+
* hook that can mutate the outgoing Response, so auto-commit-on-exit is not
|
|
16
|
+
* possible. Handlers must call `saveSession(ctx)` or `destroySession(ctx)`
|
|
17
|
+
* explicitly before returning a response. `loginUser` / `logoutUser`
|
|
18
|
+
* (Phase 2.4) will wrap those calls.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { session, saveSession } from "@mandujs/core/middleware";
|
|
23
|
+
* import { createCookieSessionStorage } from "@mandujs/core";
|
|
24
|
+
*
|
|
25
|
+
* const storage = createCookieSessionStorage({
|
|
26
|
+
* cookie: { secrets: [process.env.SESSION_SECRET!] },
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* export default Mandu.filling()
|
|
30
|
+
* .use(session({ storage }))
|
|
31
|
+
* .post(async (ctx) => {
|
|
32
|
+
* const s = ctx.get<Session>("session");
|
|
33
|
+
* s.set("userId", "42");
|
|
34
|
+
* await saveSession(ctx);
|
|
35
|
+
* return ctx.ok({ ok: true });
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
import type { ManduContext } from "../filling/context";
|
|
40
|
+
import type { Session, SessionStorage } from "../filling/session";
|
|
41
|
+
|
|
42
|
+
// ========== Types ==========
|
|
43
|
+
|
|
44
|
+
export interface SessionMiddlewareOptions {
|
|
45
|
+
/** The storage implementation. Create via `createCookieSessionStorage(...)`. */
|
|
46
|
+
storage: SessionStorage;
|
|
47
|
+
/** Context key under which the Session is attached. Default: `"session"`. */
|
|
48
|
+
attachAs?: string;
|
|
49
|
+
/** Context key under which the storage is attached (for helpers). Default: `"_sessionStorage"`. */
|
|
50
|
+
storageKey?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Middleware signature matching `jwt.ts` / `csrf.ts`. */
|
|
54
|
+
type Middleware = (ctx: ManduContext) => Promise<Response | void>;
|
|
55
|
+
|
|
56
|
+
// ========== Defaults ==========
|
|
57
|
+
|
|
58
|
+
const DEFAULT_ATTACH_KEY = "session";
|
|
59
|
+
const DEFAULT_STORAGE_KEY = "_sessionStorage";
|
|
60
|
+
|
|
61
|
+
// ========== Middleware ==========
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Attach a `Session` (and its storage) to the context for the duration of the
|
|
65
|
+
* request.
|
|
66
|
+
*
|
|
67
|
+
* Does NOT auto-commit on exit — the middleware pipeline has no after-response
|
|
68
|
+
* hook that can both (a) run after the handler returns and (b) mutate the
|
|
69
|
+
* outgoing Response. Handlers persist explicitly via {@link saveSession} or
|
|
70
|
+
* {@link destroySession}.
|
|
71
|
+
*/
|
|
72
|
+
export function session(options: SessionMiddlewareOptions): Middleware {
|
|
73
|
+
if (!options || !options.storage) {
|
|
74
|
+
throw new Error("[Mandu Session] `storage` is required (use createCookieSessionStorage)");
|
|
75
|
+
}
|
|
76
|
+
const {
|
|
77
|
+
storage,
|
|
78
|
+
attachAs = DEFAULT_ATTACH_KEY,
|
|
79
|
+
storageKey = DEFAULT_STORAGE_KEY,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
return async (ctx: ManduContext): Promise<Response | void> => {
|
|
83
|
+
const s = await storage.getSession(ctx.cookies);
|
|
84
|
+
ctx.set(attachAs, s);
|
|
85
|
+
ctx.set(storageKey, storage);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ========== Helpers ==========
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Commit the attached session via its storage, applying the resulting
|
|
93
|
+
* `Set-Cookie` header to `ctx.cookies` so the next `ctx.json/ok/redirect`
|
|
94
|
+
* picks it up automatically.
|
|
95
|
+
*
|
|
96
|
+
* No-op when the session is not dirty (unless `force: true`). Throws when the
|
|
97
|
+
* session middleware has not been installed on this request.
|
|
98
|
+
*
|
|
99
|
+
* IMPORTANT ordering: `ctx.json()` / `ctx.ok()` snapshot pending cookies at
|
|
100
|
+
* the moment they build their Response. Call `saveSession` **before** any
|
|
101
|
+
* response-producing method so the Set-Cookie makes it onto the wire.
|
|
102
|
+
*/
|
|
103
|
+
export async function saveSession(
|
|
104
|
+
ctx: ManduContext,
|
|
105
|
+
options?: { force?: boolean; attachAs?: string; storageKey?: string }
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const attachAs = options?.attachAs ?? DEFAULT_ATTACH_KEY;
|
|
108
|
+
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
109
|
+
|
|
110
|
+
const { session: s, storage } = resolveSessionAndStorage(ctx, attachAs, storageKey);
|
|
111
|
+
|
|
112
|
+
if (!options?.force && !s.isDirty()) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const setCookie = await storage.commitSession(s);
|
|
117
|
+
ctx.cookies.appendRawSetCookie(setCookie);
|
|
118
|
+
s.markClean();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Destroy the attached session: clears in-memory data AND emits an
|
|
123
|
+
* expiring Set-Cookie so the browser drops its copy.
|
|
124
|
+
*
|
|
125
|
+
* Throws when the session middleware has not been installed on this request.
|
|
126
|
+
*
|
|
127
|
+
* Same ordering caveat as {@link saveSession}: call before the
|
|
128
|
+
* response-producing method.
|
|
129
|
+
*/
|
|
130
|
+
export async function destroySession(
|
|
131
|
+
ctx: ManduContext,
|
|
132
|
+
options?: { attachAs?: string; storageKey?: string }
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const attachAs = options?.attachAs ?? DEFAULT_ATTACH_KEY;
|
|
135
|
+
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
136
|
+
|
|
137
|
+
const { session: s, storage } = resolveSessionAndStorage(ctx, attachAs, storageKey);
|
|
138
|
+
|
|
139
|
+
const setCookie = await storage.destroySession(s);
|
|
140
|
+
// Wipe in-memory state first so subsequent handler code in the same request
|
|
141
|
+
// sees an empty session (prevents accidental re-use of stale data between
|
|
142
|
+
// destroy and response emit).
|
|
143
|
+
s.clear();
|
|
144
|
+
ctx.cookies.appendRawSetCookie(setCookie);
|
|
145
|
+
// destroy() emits a new cookie; the session is now "clean" relative to that
|
|
146
|
+
// just-written state — any further mutation would re-dirty it.
|
|
147
|
+
s.markClean();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ========== Internal ==========
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fetch the Session + SessionStorage from context keys, throwing a clear
|
|
154
|
+
* error when the middleware was not installed.
|
|
155
|
+
*
|
|
156
|
+
* Uses a plain `Error` rather than `AuthenticationError` because missing
|
|
157
|
+
* middleware is a wiring mistake (500-class server error), not an end-user
|
|
158
|
+
* auth failure (401).
|
|
159
|
+
*/
|
|
160
|
+
function resolveSessionAndStorage(
|
|
161
|
+
ctx: ManduContext,
|
|
162
|
+
attachAs: string,
|
|
163
|
+
storageKey: string
|
|
164
|
+
): { session: Session; storage: SessionStorage } {
|
|
165
|
+
const s = ctx.get<Session>(attachAs);
|
|
166
|
+
const storage = ctx.get<SessionStorage>(storageKey);
|
|
167
|
+
if (!s || !storage) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
"[Mandu Session] saveSession/destroySession called without session() middleware installed. " +
|
|
170
|
+
"Add `.use(session({ storage }))` to your filling chain."
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return { session: s, storage };
|
|
174
|
+
}
|