@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.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. 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
+ }