@mandujs/core 0.21.0 → 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 (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Secure headers middleware tests
3
+ *
4
+ * Covers:
5
+ * - `buildCsp` directive merging + nonce interpolation
6
+ * - `secure` middleware plugin (beforeHandle/afterHandle) applied to a real
7
+ * `ManduContext` + `Response`, matching how the lifecycle would drive it
8
+ *
9
+ * Fixture style mirrors `tests/middleware/csrf.test.ts` and
10
+ * `src/middleware/oauth/__tests__/oauth.test.ts`:
11
+ * - Real `Request` / `Response` / `ManduContext` — no mocks
12
+ * - Middleware is executed manually (before → handler → after) to simulate
13
+ * the Elysia-style lifecycle pipeline
14
+ */
15
+ import { describe, it, expect } from "bun:test";
16
+ import {
17
+ secure,
18
+ buildCsp,
19
+ applySecureHeadersToResponse,
20
+ DEFAULT_CSP_DIRECTIVES,
21
+ type SecureMiddlewareOptions,
22
+ type CspOptions,
23
+ } from "../index";
24
+ import { ManduContext } from "../../../filling/context";
25
+ import type { MiddlewarePlugin } from "../../../filling/filling";
26
+
27
+ // ========== Helpers ==========
28
+
29
+ function makeReq(
30
+ url: string,
31
+ init: RequestInit & { cookie?: string } = {}
32
+ ): Request {
33
+ const { cookie, headers: rawHeaders, ...rest } = init;
34
+ const headers = new Headers(rawHeaders as HeadersInit | undefined);
35
+ if (cookie) headers.set("cookie", cookie);
36
+ return new Request(url, { ...rest, headers });
37
+ }
38
+
39
+ function makeCtx(req: Request): ManduContext {
40
+ return new ManduContext(req);
41
+ }
42
+
43
+ /**
44
+ * Drive the full plugin pipeline:
45
+ * 1. beforeHandle (may mutate ctx)
46
+ * 2. handler produces a Response
47
+ * 3. afterHandle (returns possibly-wrapped Response)
48
+ */
49
+ async function runPlugin(
50
+ plugin: MiddlewarePlugin,
51
+ ctx: ManduContext,
52
+ handler: (ctx: ManduContext) => Response | Promise<Response>
53
+ ): Promise<Response> {
54
+ if (plugin.beforeHandle) {
55
+ const early = await plugin.beforeHandle(ctx);
56
+ if (early instanceof Response) return early;
57
+ }
58
+ let res = await handler(ctx);
59
+ if (plugin.afterHandle) {
60
+ res = await plugin.afterHandle(ctx, res);
61
+ }
62
+ return res;
63
+ }
64
+
65
+ // ============================================
66
+ // buildCsp
67
+ // ============================================
68
+
69
+ describe("buildCsp", () => {
70
+ it("emits default directives including default-src 'self'", () => {
71
+ const built = buildCsp();
72
+ expect(built.name).toBe("Content-Security-Policy");
73
+ expect(built.header).toContain("default-src 'self'");
74
+ expect(built.header).toContain("object-src 'none'");
75
+ expect(built.header).toContain("frame-ancestors 'none'");
76
+ });
77
+
78
+ it("interpolates {NONCE} into script-src when nonce is enabled", () => {
79
+ const built = buildCsp({ nonce: true });
80
+ expect(built.nonce).toBeDefined();
81
+ expect(built.nonce!.length).toBeGreaterThan(0);
82
+ // The nonce should appear as part of a `'nonce-...'` token in script-src.
83
+ const scriptSrcMatch = built.header.match(/script-src ([^;]+)/);
84
+ expect(scriptSrcMatch).not.toBeNull();
85
+ expect(scriptSrcMatch![1]).toContain(`'nonce-${built.nonce}'`);
86
+ // Placeholder literal should NOT leak through.
87
+ expect(built.header).not.toContain("{NONCE}");
88
+ });
89
+
90
+ it("reuses the same nonce across script-src and style-src", () => {
91
+ const built = buildCsp({ nonce: true });
92
+ const nonce = built.nonce!;
93
+ const scriptMatch = built.header.match(/script-src [^;]*'nonce-([^']+)'/);
94
+ const styleMatch = built.header.match(/style-src [^;]*'nonce-([^']+)'/);
95
+ expect(scriptMatch).not.toBeNull();
96
+ expect(styleMatch).not.toBeNull();
97
+ expect(scriptMatch![1]).toBe(nonce);
98
+ expect(styleMatch![1]).toBe(nonce);
99
+ });
100
+
101
+ it("accepts a caller-supplied literal nonce verbatim", () => {
102
+ const built = buildCsp({ nonce: "mY-NoNcE_123" });
103
+ expect(built.nonce).toBe("mY-NoNcE_123");
104
+ expect(built.header).toContain("'nonce-mY-NoNcE_123'");
105
+ });
106
+
107
+ it("flips header name to Content-Security-Policy-Report-Only when reportOnly is set", () => {
108
+ const built = buildCsp({ reportOnly: true });
109
+ expect(built.name).toBe("Content-Security-Policy-Report-Only");
110
+ });
111
+
112
+ it("emits bare directive names for empty-array directives", () => {
113
+ // `upgrade-insecure-requests` is a flag-style directive in defaults.
114
+ const built = buildCsp();
115
+ // Should appear as a standalone token, not as "upgrade-insecure-requests ".
116
+ expect(built.header).toMatch(/\bupgrade-insecure-requests(;|$)/);
117
+ });
118
+
119
+ it("merges caller directives by replacing default values for that key", () => {
120
+ const built = buildCsp({
121
+ directives: { "default-src": ["'self'", "https://cdn.example.com"] },
122
+ });
123
+ expect(built.header).toContain("default-src 'self' https://cdn.example.com");
124
+ // Other defaults must survive.
125
+ expect(built.header).toContain("object-src 'none'");
126
+ });
127
+
128
+ it("preserves literal CSP keywords like 'self' and 'none' exactly", () => {
129
+ const built = buildCsp({
130
+ directives: { "script-src": ["'self'", "'none'", "'strict-dynamic'"] },
131
+ });
132
+ const match = built.header.match(/script-src ([^;]+)/);
133
+ expect(match).not.toBeNull();
134
+ expect(match![1]).toBe("'self' 'none' 'strict-dynamic'");
135
+ });
136
+
137
+ it("normalizes camelCase directive names to kebab-case", () => {
138
+ const built = buildCsp({
139
+ directives: { scriptSrc: ["'self'"], frameAncestors: ["'self'"] },
140
+ });
141
+ // Normalized directive names should land in the header.
142
+ expect(built.header).toContain("script-src 'self'");
143
+ expect(built.header).toContain("frame-ancestors 'self'");
144
+ // And should NOT appear in their camelCase form.
145
+ expect(built.header).not.toMatch(/\bscriptSrc\b/);
146
+ });
147
+
148
+ it("strips broken nonce tokens when nonce is disabled (defaults path)", () => {
149
+ const built = buildCsp({ nonce: false });
150
+ // No `'nonce-'` token should leak through from defaults.
151
+ expect(built.header).not.toContain("'nonce-");
152
+ // The surviving script-src must still list 'self' from defaults.
153
+ expect(built.header).toContain("script-src 'self'");
154
+ // Sanity: DEFAULT_CSP_DIRECTIVES declared a placeholder.
155
+ expect(DEFAULT_CSP_DIRECTIVES["script-src"]).toContain("'nonce-{NONCE}'");
156
+ });
157
+ });
158
+
159
+ // ============================================
160
+ // secure() middleware — default behavior
161
+ // ============================================
162
+
163
+ describe("secure middleware: defaults", () => {
164
+ it("sets all expected default headers on the response", async () => {
165
+ const plugin = secure();
166
+ const ctx = makeCtx(makeReq("https://example.com/"));
167
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
168
+
169
+ expect(res.headers.get("Content-Security-Policy")).toBeTruthy();
170
+ expect(res.headers.get("X-Frame-Options")).toBe("DENY");
171
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
172
+ expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
173
+ expect(res.headers.get("Permissions-Policy")).toContain("camera=()");
174
+ expect(res.headers.get("X-XSS-Protection")).toBe("0");
175
+ // HSTS requires https — which the fixture uses.
176
+ expect(res.headers.get("Strict-Transport-Security")).toContain("max-age=");
177
+ });
178
+
179
+ it("omits the CSP header when csp: false", async () => {
180
+ const plugin = secure({ csp: false });
181
+ const ctx = makeCtx(makeReq("https://example.com/"));
182
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
183
+ expect(res.headers.get("Content-Security-Policy")).toBeNull();
184
+ expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeNull();
185
+ });
186
+
187
+ it("omits Strict-Transport-Security when hsts: false", async () => {
188
+ const plugin = secure({ hsts: false });
189
+ const ctx = makeCtx(makeReq("https://example.com/"));
190
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
191
+ expect(res.headers.get("Strict-Transport-Security")).toBeNull();
192
+ });
193
+
194
+ it("emits HSTS only for HTTPS requests", async () => {
195
+ const plugin = secure();
196
+ const httpCtx = makeCtx(makeReq("http://example.com/"));
197
+ const httpRes = await runPlugin(plugin, httpCtx, (c) => c.ok({ ok: true }));
198
+ expect(httpRes.headers.get("Strict-Transport-Security")).toBeNull();
199
+
200
+ const httpsCtx = makeCtx(makeReq("https://example.com/"));
201
+ const httpsRes = await runPlugin(plugin, httpsCtx, (c) => c.ok({ ok: true }));
202
+ expect(httpsRes.headers.get("Strict-Transport-Security")).toContain("max-age=");
203
+ });
204
+
205
+ it("respects x-forwarded-proto: https for HSTS emission", async () => {
206
+ const plugin = secure();
207
+ const req = makeReq("http://example.com/", {
208
+ headers: { "x-forwarded-proto": "https" },
209
+ });
210
+ const res = await runPlugin(plugin, makeCtx(req), (c) => c.ok({ ok: true }));
211
+ expect(res.headers.get("Strict-Transport-Security")).toContain("max-age=15552000");
212
+ expect(res.headers.get("Strict-Transport-Security")).toContain("includeSubDomains");
213
+ });
214
+
215
+ it("supports frameOptions: SAMEORIGIN", async () => {
216
+ const plugin = secure({ frameOptions: "SAMEORIGIN" });
217
+ const ctx = makeCtx(makeReq("https://example.com/"));
218
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
219
+ expect(res.headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
220
+ });
221
+
222
+ it("omits X-Content-Type-Options when noSniff: false", async () => {
223
+ const plugin = secure({ noSniff: false });
224
+ const ctx = makeCtx(makeReq("https://example.com/"));
225
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
226
+ expect(res.headers.get("X-Content-Type-Options")).toBeNull();
227
+ });
228
+
229
+ it("applies a custom referrerPolicy value", async () => {
230
+ const plugin = secure({ referrerPolicy: "no-referrer" });
231
+ const ctx = makeCtx(makeReq("https://example.com/"));
232
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
233
+ expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
234
+ });
235
+
236
+ it("emits default Permissions-Policy with deny-by-default list", async () => {
237
+ const plugin = secure();
238
+ const ctx = makeCtx(makeReq("https://example.com/"));
239
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
240
+ const pp = res.headers.get("Permissions-Policy");
241
+ expect(pp).toBeTruthy();
242
+ expect(pp).toContain("camera=()");
243
+ expect(pp).toContain("microphone=()");
244
+ expect(pp).toContain("geolocation=()");
245
+ expect(pp).toContain("payment=()");
246
+ expect(pp).toContain("usb=()");
247
+ expect(pp).toContain("interest-cohort=()");
248
+ });
249
+
250
+ it("sets extra headers verbatim", async () => {
251
+ const plugin = secure({
252
+ extra: { "X-Custom-Header": "hello", "X-Build-Id": "abc123" },
253
+ });
254
+ const ctx = makeCtx(makeReq("https://example.com/"));
255
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
256
+ expect(res.headers.get("X-Custom-Header")).toBe("hello");
257
+ expect(res.headers.get("X-Build-Id")).toBe("abc123");
258
+ });
259
+
260
+ it("stashes the CSP nonce on ctx.get('csp-nonce') during request", async () => {
261
+ const plugin = secure({ csp: { nonce: true } });
262
+ const ctx = makeCtx(makeReq("https://example.com/"));
263
+
264
+ let nonceDuringHandler: string | undefined;
265
+ const res = await runPlugin(plugin, ctx, (c) => {
266
+ nonceDuringHandler = c.get<string>("csp-nonce");
267
+ return c.ok({ ok: true });
268
+ });
269
+
270
+ expect(nonceDuringHandler).toBeDefined();
271
+ expect(nonceDuringHandler!.length).toBeGreaterThan(0);
272
+ // Header must agree with the nonce the handler saw.
273
+ const cspHeader = res.headers.get("Content-Security-Policy");
274
+ expect(cspHeader).toContain(`'nonce-${nonceDuringHandler}'`);
275
+ });
276
+
277
+ it("preserves handler-set headers (e.g. Content-Type)", async () => {
278
+ const plugin = secure();
279
+ const ctx = makeCtx(makeReq("https://example.com/"));
280
+ const res = await runPlugin(plugin, ctx, () =>
281
+ new Response("hi", {
282
+ status: 200,
283
+ headers: { "Content-Type": "text/plain; charset=utf-8", "X-App-Header": "keep-me" },
284
+ })
285
+ );
286
+ expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
287
+ expect(res.headers.get("X-App-Header")).toBe("keep-me");
288
+ // Secure headers still added alongside.
289
+ expect(res.headers.get("X-Frame-Options")).toBe("DENY");
290
+ });
291
+
292
+ it("is idempotent — running the middleware twice yields the same headers", async () => {
293
+ const plugin = secure();
294
+ const ctx = makeCtx(makeReq("https://example.com/"));
295
+ const firstPass = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
296
+ // Apply afterHandle a second time to simulate accidental double-wiring.
297
+ const secondPass = await plugin.afterHandle!(ctx, firstPass);
298
+ // Each unique header name should have exactly one value.
299
+ const xfoValues = secondPass.headers.getSetCookie?.() ?? []; // no set-cookie involvement
300
+ expect(xfoValues.length).toBe(0);
301
+ expect(secondPass.headers.get("X-Frame-Options")).toBe("DENY");
302
+ expect(secondPass.headers.get("X-Content-Type-Options")).toBe("nosniff");
303
+ // Verify CSP is a single header (not duplicated).
304
+ const csp = secondPass.headers.get("Content-Security-Policy");
305
+ expect(csp).toBeTruthy();
306
+ // The value should not contain a semicolon-doubled policy (rough smoke check).
307
+ expect(csp!.split("default-src").length).toBe(2);
308
+ });
309
+
310
+ it("honors Report-Only mode via csp.reportOnly", async () => {
311
+ const plugin = secure({ csp: { reportOnly: true } });
312
+ const ctx = makeCtx(makeReq("https://example.com/"));
313
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
314
+ expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeTruthy();
315
+ expect(res.headers.get("Content-Security-Policy")).toBeNull();
316
+ });
317
+
318
+ it("allows disabling X-XSS-Protection via xssProtection: false", async () => {
319
+ const plugin = secure({ xssProtection: false });
320
+ const ctx = makeCtx(makeReq("https://example.com/"));
321
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
322
+ expect(res.headers.get("X-XSS-Protection")).toBeNull();
323
+ });
324
+
325
+ it("supports custom HSTS maxAge + preload combo", async () => {
326
+ const plugin = secure({
327
+ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
328
+ });
329
+ const ctx = makeCtx(makeReq("https://example.com/"));
330
+ const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
331
+ const hsts = res.headers.get("Strict-Transport-Security");
332
+ expect(hsts).toBe("max-age=63072000; includeSubDomains; preload");
333
+ });
334
+ });
335
+
336
+ // ============================================
337
+ // Stand-alone applySecureHeadersToResponse
338
+ // ============================================
339
+
340
+ describe("applySecureHeadersToResponse", () => {
341
+ it("wraps an arbitrary Response with the default header set", () => {
342
+ const base = new Response("hello", {
343
+ status: 200,
344
+ headers: { "Content-Type": "text/plain" },
345
+ });
346
+ const { response } = applySecureHeadersToResponse(base, { hsts: false });
347
+ expect(response.headers.get("X-Frame-Options")).toBe("DENY");
348
+ expect(response.headers.get("Content-Type")).toBe("text/plain");
349
+ });
350
+
351
+ it("returns the generated nonce when csp.nonce: true", () => {
352
+ const base = new Response(null, { status: 204 });
353
+ const { nonce, response } = applySecureHeadersToResponse(base, {
354
+ hsts: false,
355
+ csp: { nonce: true },
356
+ });
357
+ expect(nonce).toBeDefined();
358
+ expect(response.headers.get("Content-Security-Policy")).toContain(`'nonce-${nonce}'`);
359
+ });
360
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Content-Security-Policy builder
3
+ *
4
+ * Standalone because CSP has many directives, user overrides need to merge
5
+ * deterministically with defaults, and per-request nonce interpolation needs
6
+ * a single authoritative code path so `script-src` and `style-src` are
7
+ * guaranteed to share the same nonce (OWASP recommendation — one nonce per
8
+ * request, reused across directives).
9
+ *
10
+ * Consumers:
11
+ * - `secure()` middleware (via `buildCsp`)
12
+ * - Callers who want ONLY CSP (public re-export from the secure barrel)
13
+ *
14
+ * @see https://www.w3.org/TR/CSP3/
15
+ * @see https://owasp.org/www-project-secure-headers/
16
+ */
17
+
18
+ // ========== Types ==========
19
+
20
+ export interface CspOptions {
21
+ /** Directive map. Values are joined with space. Merged over defaults. */
22
+ directives?: Record<string, string[]>;
23
+ /** When true, emits `Content-Security-Policy-Report-Only` instead. */
24
+ reportOnly?: boolean;
25
+ /**
26
+ * Nonce source:
27
+ * - `true` → generate a fresh 128-bit random nonce per call
28
+ * - string → use the provided literal nonce value (must be base64url-safe)
29
+ * - `false` / undefined → no nonce; any `{NONCE}` placeholder is stripped
30
+ */
31
+ nonce?: boolean | string | false;
32
+ }
33
+
34
+ export interface BuiltCsp {
35
+ /** Final header value. */
36
+ header: string;
37
+ /** Which header name to set. */
38
+ name: "Content-Security-Policy" | "Content-Security-Policy-Report-Only";
39
+ /** Populated only when `nonce` resolved to a concrete value. */
40
+ nonce?: string;
41
+ }
42
+
43
+ // ========== Defaults ==========
44
+
45
+ /**
46
+ * Production-safe SSR + islands defaults.
47
+ *
48
+ * Key choices:
49
+ * - `'strict-dynamic'` on `script-src` means: once a trusted nonce-authorized
50
+ * script loads, further scripts it creates inherit trust. This allows
51
+ * hydration bundles to lazy-load chunks without reloading their nonce.
52
+ * Modern browsers (CSP 3+) honor this; older browsers fall back to the
53
+ * nonce allowlist, which still works for the SSR-emitted scripts.
54
+ * - `frame-ancestors 'none'` supersedes `X-Frame-Options: DENY` for modern
55
+ * browsers. We still emit XFO for legacy clients.
56
+ * - `upgrade-insecure-requests` silently rewrites `http:` subresources to
57
+ * `https:`. Safe default for modern apps; cheap insurance against mixed
58
+ * content from third-party embeds.
59
+ * - NO `'unsafe-inline'` / `'unsafe-eval'`. If your app needs them, opt in
60
+ * explicitly via `directives` — don't let them sneak in as defaults.
61
+ */
62
+ export const DEFAULT_CSP_DIRECTIVES: Readonly<Record<string, readonly string[]>> = Object.freeze({
63
+ "default-src": ["'self'"],
64
+ "script-src": ["'self'", "'nonce-{NONCE}'", "'strict-dynamic'"],
65
+ "style-src": ["'self'", "'nonce-{NONCE}'", "https://fonts.googleapis.com"],
66
+ "font-src": ["'self'", "https://fonts.gstatic.com", "data:"],
67
+ "img-src": ["'self'", "data:", "https:"],
68
+ "connect-src": ["'self'"],
69
+ "frame-ancestors": ["'none'"],
70
+ "base-uri": ["'self'"],
71
+ "form-action": ["'self'"],
72
+ "object-src": ["'none'"],
73
+ "upgrade-insecure-requests": [],
74
+ });
75
+
76
+ /** Placeholder literal that `buildCsp` rewrites to the real nonce value. */
77
+ export const NONCE_PLACEHOLDER = "{NONCE}";
78
+
79
+ // ========== Public API ==========
80
+
81
+ /**
82
+ * Build a single `Content-Security-Policy` header from defaults + overrides.
83
+ *
84
+ * Merge semantics:
85
+ * - User-supplied directives REPLACE the default values for that directive
86
+ * (caller owns the full value list). We do not concatenate, because CSP
87
+ * parsing is order-sensitive and partial merges surprise callers.
88
+ * - Directive names are normalized to lowercase-kebab. Caller may supply
89
+ * either `"script-src"` or `"scriptSrc"`; both map to `script-src`.
90
+ * - Empty-array directives emit as bare directive names (e.g.
91
+ * `upgrade-insecure-requests`) — required by the CSP grammar for flag
92
+ * directives.
93
+ *
94
+ * Nonce handling:
95
+ * - If `nonce === true`, we generate a per-call nonce via
96
+ * `crypto.getRandomValues` (16 bytes → 128 bits of entropy, base64url).
97
+ * - If `nonce === string`, we trust the caller's value verbatim. Useful for
98
+ * integrating with a pre-existing per-request nonce generator.
99
+ * - The SAME nonce is substituted into every `{NONCE}` occurrence across
100
+ * ALL directives (script-src, style-src, ...). Per OWASP guidance.
101
+ * - If no nonce, `{NONCE}` placeholders are stripped along with their
102
+ * surrounding `'nonce-...'` wrapper to avoid emitting broken tokens.
103
+ */
104
+ export function buildCsp(options: CspOptions = {}): BuiltCsp {
105
+ const nonce = resolveNonce(options.nonce);
106
+
107
+ // Normalize + merge.
108
+ const merged: Record<string, string[]> = {};
109
+ for (const [k, v] of Object.entries(DEFAULT_CSP_DIRECTIVES)) {
110
+ merged[normalizeDirectiveName(k)] = [...v];
111
+ }
112
+ if (options.directives) {
113
+ for (const [k, v] of Object.entries(options.directives)) {
114
+ if (!Array.isArray(v)) continue;
115
+ merged[normalizeDirectiveName(k)] = [...v];
116
+ }
117
+ }
118
+
119
+ const parts: string[] = [];
120
+ for (const [name, values] of Object.entries(merged)) {
121
+ const processed = values
122
+ .map((v) => interpolateNonce(v, nonce))
123
+ // Strip broken nonce tokens when no nonce was produced.
124
+ .filter((v): v is string => v !== null);
125
+
126
+ if (processed.length === 0) {
127
+ // Flag-style directive (no value list) — emit bare name.
128
+ parts.push(name);
129
+ } else {
130
+ parts.push(`${name} ${processed.join(" ")}`);
131
+ }
132
+ }
133
+
134
+ return {
135
+ header: parts.join("; "),
136
+ name: options.reportOnly
137
+ ? "Content-Security-Policy-Report-Only"
138
+ : "Content-Security-Policy",
139
+ nonce,
140
+ };
141
+ }
142
+
143
+ // ========== Internal ==========
144
+
145
+ /**
146
+ * Resolve the `nonce` option to a concrete string (or undefined for "off").
147
+ * Generation uses `crypto.getRandomValues` rather than `crypto.randomUUID`
148
+ * to match OWASP's recommendation of ≥128 bits of entropy with raw random
149
+ * bytes (UUIDs have lower effective entropy due to version/variant bits).
150
+ */
151
+ function resolveNonce(opt: CspOptions["nonce"]): string | undefined {
152
+ if (opt === true) {
153
+ const bytes = new Uint8Array(16);
154
+ crypto.getRandomValues(bytes);
155
+ return base64UrlEncode(bytes);
156
+ }
157
+ if (typeof opt === "string" && opt.length > 0) {
158
+ return opt;
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ /**
164
+ * Replace `{NONCE}` in a single directive value.
165
+ *
166
+ * Returns `null` to signal "this value depended on a nonce that was never
167
+ * produced — drop it entirely" so callers don't emit `'nonce-'` (broken
168
+ * token). Returns the original value when no placeholder is present.
169
+ */
170
+ function interpolateNonce(value: string, nonce: string | undefined): string | null {
171
+ if (!value.includes(NONCE_PLACEHOLDER)) return value;
172
+ if (!nonce) return null;
173
+ return value.split(NONCE_PLACEHOLDER).join(nonce);
174
+ }
175
+
176
+ /**
177
+ * Accepts `"scriptSrc"` / `"script-src"` / `"Script-Src"` → `"script-src"`.
178
+ * camelCase → kebab-case via the standard regex; already-kebab inputs are
179
+ * lowercased without further transformation.
180
+ */
181
+ function normalizeDirectiveName(name: string): string {
182
+ return name
183
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
184
+ .toLowerCase();
185
+ }
186
+
187
+ function base64UrlEncode(bytes: Uint8Array): string {
188
+ let binary = "";
189
+ for (let i = 0; i < bytes.length; i++) {
190
+ binary += String.fromCharCode(bytes[i]);
191
+ }
192
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
193
+ }