@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,417 +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
- }
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
+ }