@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,120 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { newId, newShortId, _resolveGenerator } from "../index";
3
+
4
+ const UUID_REGEX =
5
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ const HEX8_REGEX = /^[0-9a-f]{8}$/i;
7
+
8
+ interface CryptoLike {
9
+ randomUUIDv7?: () => string;
10
+ randomUUID: () => string;
11
+ }
12
+
13
+ describe("@mandujs/core/id — newId", () => {
14
+ it("returns a canonical UUID string", () => {
15
+ const id = newId();
16
+ expect(id).toMatch(UUID_REGEX);
17
+ expect(id.length).toBe(36);
18
+ });
19
+
20
+ it("returns distinct values across calls", () => {
21
+ const seen = new Set<string>();
22
+ for (let i = 0; i < 1000; i++) {
23
+ seen.add(newId());
24
+ }
25
+ expect(seen.size).toBe(1000);
26
+ });
27
+
28
+ it("is monotonic: lexicographic sort preserves generation order (v7 property)", async () => {
29
+ // Skip this invariant if neither global exposes v7 — the v4 fallback is
30
+ // correctly non-monotonic by design.
31
+ const c = globalThis.crypto as unknown as CryptoLike;
32
+ const bun = (globalThis as unknown as { Bun?: { randomUUIDv7?: () => string } }).Bun;
33
+ const v7Available =
34
+ typeof c.randomUUIDv7 === "function" ||
35
+ (bun !== undefined && typeof bun.randomUUIDv7 === "function");
36
+ if (!v7Available) {
37
+ return;
38
+ }
39
+
40
+ const generated: string[] = [];
41
+ for (let i = 0; i < 1000; i++) {
42
+ generated.push(newId());
43
+ // Force sub-millisecond spread across a few ticks so the time component
44
+ // advances at least once during the run. UUID v7 counter handles
45
+ // intra-millisecond monotonicity on its own.
46
+ if (i % 200 === 0) {
47
+ await new Promise((r) => setTimeout(r, 1));
48
+ }
49
+ }
50
+
51
+ const sorted = [...generated].sort();
52
+
53
+ // Require overall monotonic trend: the full sorted list equals the
54
+ // generation order. Bun's v7 implementation uses a per-millisecond
55
+ // monotonic counter, so ties within the same ms remain ordered.
56
+ expect(sorted).toEqual(generated);
57
+ });
58
+ });
59
+
60
+ describe("@mandujs/core/id — newShortId", () => {
61
+ it("returns exactly 8 hex characters", () => {
62
+ const short = newShortId();
63
+ expect(short).toMatch(HEX8_REGEX);
64
+ expect(short.length).toBe(8);
65
+ });
66
+
67
+ it("matches the prefix of the underlying UUID", () => {
68
+ // The short form is defined as the first 8 chars of the full ID, which is
69
+ // the first hex group of the UUID (pre-hyphen).
70
+ for (let i = 0; i < 50; i++) {
71
+ const short = newShortId();
72
+ expect(short).not.toContain("-");
73
+ }
74
+ });
75
+ });
76
+
77
+ describe("@mandujs/core/id — generator resolution", () => {
78
+ it("prefers crypto.randomUUIDv7 when present", () => {
79
+ const sentinel = "01912345-6789-7abc-def0-123456789abc";
80
+ const fakeCrypto = {
81
+ randomUUIDv7: () => sentinel,
82
+ randomUUID: () => "00000000-0000-4000-8000-000000000000",
83
+ };
84
+ const fakeBun = { randomUUIDv7: () => "SHOULD_NOT_BE_CALLED" };
85
+ const gen = _resolveGenerator(fakeCrypto, fakeBun);
86
+ expect(gen()).toBe(sentinel);
87
+ });
88
+
89
+ it("uses Bun.randomUUIDv7 when crypto.randomUUIDv7 is absent", () => {
90
+ const sentinel = "01abcdef-0123-7456-8789-abcdef012345";
91
+ const fakeCrypto = {
92
+ randomUUID: () => "00000000-0000-4000-8000-000000000000",
93
+ };
94
+ const fakeBun = { randomUUIDv7: () => sentinel };
95
+ const gen = _resolveGenerator(fakeCrypto, fakeBun);
96
+ expect(gen()).toBe(sentinel);
97
+ });
98
+
99
+ it("falls back to crypto.randomUUID when neither v7 source is available", () => {
100
+ const fakeCrypto = {
101
+ // No randomUUIDv7 — force fallback.
102
+ randomUUID: () => "00000000-0000-4000-8000-000000000000",
103
+ };
104
+ const fakeBun = {}; // also no randomUUIDv7
105
+ const gen = _resolveGenerator(fakeCrypto, fakeBun);
106
+ const id = gen();
107
+ expect(id).toMatch(UUID_REGEX);
108
+ expect(id.length).toBe(36);
109
+ });
110
+
111
+ it("falls back to crypto.randomUUID when Bun stub lacks randomUUIDv7", () => {
112
+ const fakeCrypto = {
113
+ randomUUID: () => "11111111-1111-4111-8111-111111111111",
114
+ };
115
+ // Pass an empty stub explicitly rather than `undefined` (which would
116
+ // trigger the default-parameter fallback to the real Bun global).
117
+ const gen = _resolveGenerator(fakeCrypto, {});
118
+ expect(gen()).toBe("11111111-1111-4111-8111-111111111111");
119
+ });
120
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @mandujs/core/id
3
+ *
4
+ * Centralized ID generation utilities.
5
+ *
6
+ * Uses `crypto.randomUUIDv7()` when available (Bun 1.3+) so that generated IDs
7
+ * are time-ordered — lexicographic string sort matches chronological creation
8
+ * order. This makes v7 UUIDs ideal for log correlation, database primary keys,
9
+ * and any context where sortability is useful.
10
+ *
11
+ * On older runtimes we fall back to `crypto.randomUUID()` (v4). The fallback
12
+ * emits a single dev-only warning so integrators notice the reduced guarantee,
13
+ * and remains silent in production.
14
+ */
15
+
16
+ interface CryptoWithUUIDv7 {
17
+ randomUUIDv7?: () => string;
18
+ randomUUID: () => string;
19
+ }
20
+
21
+ interface BunWithUUIDv7 {
22
+ randomUUIDv7?: () => string;
23
+ }
24
+
25
+ const FALLBACK_WARNING =
26
+ "[@mandujs/core/id] randomUUIDv7 is unavailable — falling back to randomUUID (v4). IDs will not be time-ordered. Upgrade to Bun >= 1.3 for v7 support.";
27
+
28
+ let fallbackWarned = false;
29
+
30
+ function getCrypto(): CryptoWithUUIDv7 {
31
+ // `crypto` is available globally in Bun, Node 20+, Deno, and modern browsers.
32
+ // We type-assert via the narrow interface we actually use.
33
+ return globalThis.crypto as unknown as CryptoWithUUIDv7;
34
+ }
35
+
36
+ function getBun(): BunWithUUIDv7 | undefined {
37
+ // `Bun` is only present when running under the Bun runtime. Access via
38
+ // a dynamic lookup so the module stays importable in Node / browsers.
39
+ const g = globalThis as unknown as { Bun?: BunWithUUIDv7 };
40
+ return g.Bun;
41
+ }
42
+
43
+ function warnFallbackOnce(): void {
44
+ if (fallbackWarned) return;
45
+ fallbackWarned = true;
46
+ if (
47
+ typeof process !== "undefined" &&
48
+ process.env &&
49
+ process.env.NODE_ENV !== "production"
50
+ ) {
51
+ console.warn(FALLBACK_WARNING);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Resolves the concrete UUID generator function once per call. Exported
57
+ * internally so tests can validate the resolution order without monkey
58
+ * patching non-configurable globals.
59
+ *
60
+ * @internal
61
+ */
62
+ export function _resolveGenerator(
63
+ c: CryptoWithUUIDv7 = getCrypto(),
64
+ bun: BunWithUUIDv7 | undefined = getBun(),
65
+ ): () => string {
66
+ if (typeof c.randomUUIDv7 === "function") {
67
+ return c.randomUUIDv7.bind(c);
68
+ }
69
+ if (bun && typeof bun.randomUUIDv7 === "function") {
70
+ return bun.randomUUIDv7.bind(bun);
71
+ }
72
+ warnFallbackOnce();
73
+ return c.randomUUID.bind(c);
74
+ }
75
+
76
+ /**
77
+ * Generates a UUID v7 — a time-ordered UUID whose string sort matches
78
+ * chronological order.
79
+ *
80
+ * Resolution order:
81
+ * 1. `crypto.randomUUIDv7()` — web standard, Node 24+, newer Bun
82
+ * 2. `Bun.randomUUIDv7()` — Bun 1.3+ runtime API
83
+ * 3. `crypto.randomUUID()` — v4 fallback (non-monotonic)
84
+ *
85
+ * The fallback path emits a one-time warning in dev mode.
86
+ *
87
+ * @returns A 36-character UUID string in canonical 8-4-4-4-12 form.
88
+ */
89
+ export function newId(): string {
90
+ return _resolveGenerator()();
91
+ }
92
+
93
+ /**
94
+ * First 8 hex characters of a UUID v7 — suitable for log correlation IDs
95
+ * where collision risk is acceptable within a single request lifetime.
96
+ *
97
+ * Because v7 encodes the timestamp in its high bits, two short IDs generated
98
+ * within the same millisecond share a prefix; this is normal and expected.
99
+ * For strong uniqueness use {@link newId} instead.
100
+ *
101
+ * @returns Exactly 8 lowercase hex characters.
102
+ */
103
+ export function newShortId(): string {
104
+ return newId().slice(0, 8);
105
+ }
@@ -5,8 +5,8 @@
5
5
  * and architecture guard dashboard at /__kitchen.
6
6
  */
7
7
 
8
- export { KitchenHandler, KITCHEN_PREFIX, getKitchenErrors, clearKitchenErrors } from "./kitchen-handler";
9
- export type { KitchenOptions } from "./kitchen-handler";
8
+ export { KitchenHandler, KITCHEN_PREFIX, getKitchenErrors, clearKitchenErrors, computeAgentStats } from "./kitchen-handler";
9
+ export type { KitchenOptions, AgentStats, AgentStatsResponse } from "./kitchen-handler";
10
10
  export { ActivitySSEBroadcaster } from "./stream/activity-sse";
11
11
  export { FileTailer } from "./stream/file-tailer";
12
12
  export { GuardAPI } from "./api/guard-api";
@@ -93,6 +93,87 @@ function parseWindow(input: string): number {
93
93
  }
94
94
  }
95
95
 
96
+ // ========== Per-Agent Stats ==========
97
+
98
+ export interface AgentStats {
99
+ toolCalls: number;
100
+ failures: number;
101
+ topTools: Array<{ tool: string; count: number }>;
102
+ avgDuration: number;
103
+ firstSeen: number;
104
+ lastSeen: number;
105
+ }
106
+
107
+ export interface AgentStatsResponse {
108
+ agents: Record<string, AgentStats>;
109
+ totalAgents: number;
110
+ totalEvents: number;
111
+ }
112
+
113
+ /**
114
+ * Aggregate recent MCP events by sessionId to produce per-agent usage stats.
115
+ * Events without a sessionId are grouped under "unknown".
116
+ */
117
+ export function computeAgentStats(): AgentStatsResponse {
118
+ const events = eventBus.getRecent(500, { type: "mcp" });
119
+ const agents: Record<string, AgentStats> = {};
120
+ const toolCounts: Record<string, Map<string, number>> = {};
121
+ const durations: Record<string, number[]> = {};
122
+
123
+ for (const e of events) {
124
+ const data = (e.data ?? {}) as Record<string, unknown>;
125
+ const sessionId = typeof data.sessionId === "string" && data.sessionId
126
+ ? data.sessionId
127
+ : "unknown";
128
+
129
+ let agent = agents[sessionId];
130
+ if (!agent) {
131
+ agent = {
132
+ toolCalls: 0,
133
+ failures: 0,
134
+ topTools: [],
135
+ avgDuration: 0,
136
+ firstSeen: e.timestamp,
137
+ lastSeen: e.timestamp,
138
+ };
139
+ agents[sessionId] = agent;
140
+ toolCounts[sessionId] = new Map();
141
+ durations[sessionId] = [];
142
+ }
143
+
144
+ agent.toolCalls++;
145
+ if (e.severity === "error") agent.failures++;
146
+ if (e.timestamp < agent.firstSeen) agent.firstSeen = e.timestamp;
147
+ if (e.timestamp > agent.lastSeen) agent.lastSeen = e.timestamp;
148
+
149
+ const tool = typeof data.tool === "string" && data.tool
150
+ ? data.tool
151
+ : e.source || "unknown";
152
+ toolCounts[sessionId].set(tool, (toolCounts[sessionId].get(tool) ?? 0) + 1);
153
+
154
+ if (typeof e.duration === "number") {
155
+ durations[sessionId].push(e.duration);
156
+ }
157
+ }
158
+
159
+ for (const [sessionId, agent] of Object.entries(agents)) {
160
+ agent.topTools = Array.from(toolCounts[sessionId].entries())
161
+ .map(([tool, count]) => ({ tool, count }))
162
+ .sort((a, b) => b.count - a.count)
163
+ .slice(0, 5);
164
+ const ds = durations[sessionId];
165
+ agent.avgDuration = ds.length
166
+ ? ds.reduce((a, b) => a + b, 0) / ds.length
167
+ : 0;
168
+ }
169
+
170
+ return {
171
+ agents,
172
+ totalAgents: Object.keys(agents).length,
173
+ totalEvents: events.length,
174
+ };
175
+ }
176
+
96
177
  export class KitchenHandler {
97
178
  private sse: ActivitySSEBroadcaster;
98
179
  private guardAPI: GuardAPI;
@@ -271,6 +352,11 @@ export class KitchenHandler {
271
352
  return Response.json({ events });
272
353
  }
273
354
 
355
+ // Agent Stats API — per-agent (sessionId) aggregation of MCP events
356
+ if (sub === "/api/agent-stats" && req.method === "GET") {
357
+ return Response.json(computeAgentStats());
358
+ }
359
+
274
360
  // Cache API — cache store stats
275
361
  if ((sub === "/api/cache" || sub === "/api/cache-stats") && req.method === "GET") {
276
362
  const store = getGlobalCache();
@@ -9,6 +9,7 @@
9
9
 
10
10
  import path from "path";
11
11
  import { FileTailer } from "./file-tailer";
12
+ import { newId } from "../../id";
12
13
 
13
14
  interface SSEClient {
14
15
  id: string;
@@ -82,7 +83,7 @@ export class ActivitySSEBroadcaster {
82
83
 
83
84
  /** Create an SSE Response for a new client connection */
84
85
  createResponse(): Response {
85
- const clientId = crypto.randomUUID();
86
+ const clientId = newId();
86
87
 
87
88
  const stream = new ReadableStream<Uint8Array>({
88
89
  start: (controller) => {
@@ -0,0 +1,328 @@
1
+ /**
2
+ * CSRF Middleware Plugin
3
+ *
4
+ * Double-submit cookie pattern (stateless, no session required).
5
+ *
6
+ * cookie: __csrf=<token>
7
+ * header: x-csrf-token: <token> (or form field _csrf)
8
+ *
9
+ * Both values MUST match AND the token's HMAC signature MUST verify.
10
+ *
11
+ * Internally delegates to `Bun.CSRF.generate` / `Bun.CSRF.verify` when
12
+ * available (Bun ≥ 1.3). Verified present and stable in Bun 1.3.10:
13
+ * Bun.CSRF.generate(secret, { maxAge? }) → URL-safe base64 token
14
+ * Bun.CSRF.verify(token, { secret, maxAge? }) → boolean
15
+ *
16
+ * A pure `crypto.subtle` + `crypto.getRandomValues` fallback is provided for
17
+ * compatibility. The exported `csrf()` API is identical either way.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { csrf } from "@mandujs/core/middleware";
22
+ *
23
+ * export default Mandu.filling()
24
+ * .use(csrf({ secret: process.env.CSRF_SECRET! }))
25
+ * .post((ctx) => ctx.ok({ ok: true }));
26
+ * ```
27
+ */
28
+ import type { ManduContext, CookieOptions } from "../filling/context";
29
+
30
+ // ========== Types ==========
31
+
32
+ export interface CsrfMiddlewareOptions {
33
+ /** Required. Used to HMAC-sign CSRF tokens. */
34
+ secret: string;
35
+ /** Cookie name. Default: "__csrf". */
36
+ cookieName?: string;
37
+ /** Header name checked on unsafe methods. Default: "x-csrf-token". */
38
+ headerName?: string;
39
+ /** Form field name checked as fallback. Default: "_csrf". */
40
+ fieldName?: string;
41
+ /** Methods that skip validation. Default: ["GET","HEAD","OPTIONS"]. */
42
+ safeMethods?: string[];
43
+ /** Cookie attribute overrides (merged with sensible defaults). */
44
+ cookieOptions?: {
45
+ /** Default: false — client JS needs to read the token to submit it in a header. */
46
+ httpOnly?: boolean;
47
+ /** Default: NODE_ENV === "production". */
48
+ secure?: boolean;
49
+ /** Default: "lax". */
50
+ sameSite?: "strict" | "lax" | "none";
51
+ /** Default: "/". */
52
+ path?: string;
53
+ /** Default: 86400 (1 day). */
54
+ maxAge?: number;
55
+ /** Optional cookie domain. */
56
+ domain?: string;
57
+ };
58
+ }
59
+
60
+ /** Middleware signature matching `jwt.ts`. */
61
+ type Middleware = (ctx: ManduContext) => Promise<Response | void>;
62
+
63
+ // ========== Implementation ==========
64
+
65
+ const DEFAULT_COOKIE_NAME = "__csrf";
66
+ const DEFAULT_HEADER_NAME = "x-csrf-token";
67
+ const DEFAULT_FIELD_NAME = "_csrf";
68
+ const DEFAULT_SAFE_METHODS: readonly string[] = ["GET", "HEAD", "OPTIONS"];
69
+ const DEFAULT_MAX_AGE = 86400; // 1 day
70
+ /** Guard against memory-exhaustion attacks via oversized tokens. */
71
+ const MAX_TOKEN_LENGTH = 512;
72
+
73
+ /**
74
+ * CSRF protection middleware (double-submit cookie pattern).
75
+ *
76
+ * Behavior:
77
+ * 1. Ensures a signed CSRF token cookie is present. Issues a fresh one if
78
+ * the existing cookie is missing or its signature fails to verify.
79
+ * 2. For safe methods (GET/HEAD/OPTIONS): continues without further checks.
80
+ * 3. For unsafe methods: reads the submitted token from the configured
81
+ * header (preferred) or form field (fallback for form content types),
82
+ * then confirms:
83
+ * (a) submitted token === cookie token (constant-time equality)
84
+ * (b) the token's HMAC signature still verifies with `secret`
85
+ * Any failure returns 403 without leaking which check failed.
86
+ */
87
+ export function csrf(options: CsrfMiddlewareOptions): Middleware {
88
+ if (!options.secret || typeof options.secret !== "string") {
89
+ throw new Error("[Mandu CSRF] `secret` is required and must be a non-empty string");
90
+ }
91
+
92
+ const {
93
+ secret,
94
+ cookieName = DEFAULT_COOKIE_NAME,
95
+ headerName = DEFAULT_HEADER_NAME,
96
+ fieldName = DEFAULT_FIELD_NAME,
97
+ safeMethods = DEFAULT_SAFE_METHODS,
98
+ } = options;
99
+
100
+ const normalizedSafeMethods = new Set(safeMethods.map((m) => m.toUpperCase()));
101
+ const cookieOptions = resolveCookieOptions(options.cookieOptions);
102
+ const maxAgeSec = cookieOptions.maxAge ?? DEFAULT_MAX_AGE;
103
+
104
+ return async (ctx: ManduContext): Promise<Response | void> => {
105
+ const method = ctx.request.method.toUpperCase();
106
+
107
+ // 1. Ensure a valid CSRF cookie is present for the next unsafe request.
108
+ const existing = ctx.cookies.get(cookieName);
109
+ let activeCookieToken: string | null = null;
110
+
111
+ if (typeof existing === "string" && isAcceptableToken(existing)) {
112
+ const valid = await verifyToken(existing, secret, maxAgeSec);
113
+ if (valid) {
114
+ // Keep existing token (no unnecessary rotation).
115
+ activeCookieToken = existing;
116
+ }
117
+ }
118
+
119
+ if (activeCookieToken === null) {
120
+ activeCookieToken = await generateToken(secret, maxAgeSec);
121
+ ctx.cookies.set(cookieName, activeCookieToken, cookieOptions);
122
+ }
123
+
124
+ // 2. Safe methods pass through.
125
+ if (normalizedSafeMethods.has(method)) {
126
+ return;
127
+ }
128
+
129
+ // 3. Unsafe methods: read + validate submitted token.
130
+ const submitted = await extractSubmittedToken(ctx, headerName, fieldName);
131
+
132
+ if (!submitted || !isAcceptableToken(submitted)) {
133
+ return ctx.forbidden("CSRF token missing or invalid");
134
+ }
135
+
136
+ // Constant-time equality between submitted token and cookie token.
137
+ if (!safeEqual(submitted, activeCookieToken)) {
138
+ return ctx.forbidden("CSRF token missing or invalid");
139
+ }
140
+
141
+ // HMAC verification on the submitted token (prevents forged cookies from
142
+ // sibling subdomains since they cannot sign with our secret).
143
+ const verified = await verifyToken(submitted, secret, maxAgeSec);
144
+ if (!verified) {
145
+ return ctx.forbidden("CSRF token missing or invalid");
146
+ }
147
+
148
+ // Valid — continue.
149
+ };
150
+ }
151
+
152
+ // ========== Helpers ==========
153
+
154
+ /**
155
+ * Resolve cookie options with production-safe defaults.
156
+ *
157
+ * `httpOnly: false` by default: a CSRF token cookie needs to be readable by
158
+ * client-side JS so the app can echo it back in the header. Callers who set
159
+ * the token from the server (e.g. via a hidden form field) may opt into
160
+ * `httpOnly: true`.
161
+ */
162
+ function resolveCookieOptions(overrides?: CsrfMiddlewareOptions["cookieOptions"]): CookieOptions {
163
+ const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
164
+ return {
165
+ httpOnly: overrides?.httpOnly ?? false,
166
+ secure: overrides?.secure ?? isProd,
167
+ sameSite: overrides?.sameSite ?? "lax",
168
+ path: overrides?.path ?? "/",
169
+ maxAge: overrides?.maxAge ?? DEFAULT_MAX_AGE,
170
+ domain: overrides?.domain,
171
+ };
172
+ }
173
+
174
+ /** Read submitted token from header, falling back to form field if applicable. */
175
+ async function extractSubmittedToken(
176
+ ctx: ManduContext,
177
+ headerName: string,
178
+ fieldName: string
179
+ ): Promise<string | null> {
180
+ // Header wins when present (cheap, safe, no body consumption).
181
+ const headerVal = ctx.headers.get(headerName);
182
+ if (typeof headerVal === "string" && headerVal.length > 0) {
183
+ return headerVal;
184
+ }
185
+
186
+ // Form fallback: only when the request advertises a form-like content type.
187
+ // JSON bodies are NOT scanned — header submission is the canonical path.
188
+ const contentType = (ctx.headers.get("content-type") ?? "").toLowerCase();
189
+ const isForm =
190
+ contentType.includes("application/x-www-form-urlencoded") ||
191
+ contentType.includes("multipart/form-data");
192
+ if (!isForm) return null;
193
+
194
+ try {
195
+ // Clone so downstream handlers can still read the body.
196
+ const form = await ctx.request.clone().formData();
197
+ const fieldVal = form.get(fieldName);
198
+ return typeof fieldVal === "string" ? fieldVal : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /** Validate token shape before running expensive crypto. */
205
+ function isAcceptableToken(token: string): boolean {
206
+ return (
207
+ typeof token === "string" &&
208
+ token.length > 0 &&
209
+ token.length <= MAX_TOKEN_LENGTH
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Constant-time string comparison to avoid timing-oracle attacks.
215
+ * Returns `false` immediately on length mismatch (lengths themselves are not
216
+ * secret for our fixed-format tokens), then XORs character codes over the
217
+ * full length before folding into a single diff bit.
218
+ */
219
+ function safeEqual(a: string, b: string): boolean {
220
+ if (a.length !== b.length) return false;
221
+ let diff = 0;
222
+ for (let i = 0; i < a.length; i++) {
223
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
224
+ }
225
+ return diff === 0;
226
+ }
227
+
228
+ // ========== Token crypto (Bun.CSRF preferred, fallback to crypto.subtle) ==========
229
+
230
+ /**
231
+ * Runtime capability probe for `Bun.CSRF`. Done once at module load — avoids
232
+ * re-checking on every request and allows non-Bun runtimes to use the
233
+ * fallback implementation.
234
+ */
235
+ const bunCsrf = resolveBunCsrf();
236
+
237
+ function resolveBunCsrf():
238
+ | {
239
+ generate: (secret: string, options?: { maxAge?: number }) => string;
240
+ verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
241
+ }
242
+ | null {
243
+ if (typeof globalThis === "undefined") return null;
244
+ const bun = (globalThis as { Bun?: { CSRF?: unknown } }).Bun;
245
+ if (!bun || typeof bun !== "object" || bun === null) return null;
246
+ const csrfApi = (bun as { CSRF?: unknown }).CSRF;
247
+ if (!csrfApi || typeof csrfApi !== "object") return null;
248
+ const api = csrfApi as {
249
+ generate?: unknown;
250
+ verify?: unknown;
251
+ };
252
+ if (typeof api.generate !== "function" || typeof api.verify !== "function") {
253
+ return null;
254
+ }
255
+ return api as {
256
+ generate: (secret: string, options?: { maxAge?: number }) => string;
257
+ verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
258
+ };
259
+ }
260
+
261
+ async function generateToken(secret: string, maxAgeSec: number): Promise<string> {
262
+ if (bunCsrf) {
263
+ // Bun.CSRF handles timestamp + random + HMAC in native code.
264
+ return bunCsrf.generate(secret, { maxAge: maxAgeSec });
265
+ }
266
+ return fallbackGenerate(secret);
267
+ }
268
+
269
+ async function verifyToken(token: string, secret: string, maxAgeSec: number): Promise<boolean> {
270
+ if (bunCsrf) {
271
+ try {
272
+ return bunCsrf.verify(token, { secret, maxAge: maxAgeSec });
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+ return fallbackVerify(token, secret);
278
+ }
279
+
280
+ // ----- Fallback (no Bun.CSRF available) -----
281
+
282
+ /**
283
+ * Token format: `<random-b64url>.<hmac-b64url>`
284
+ * - random: 32 bytes via `crypto.getRandomValues`
285
+ * - hmac: HMAC-SHA256(random, secret)
286
+ *
287
+ * Same pattern as `packages/core/src/filling/session.ts` (`hmacSign`,
288
+ * line 216-227) so we don't introduce a second crypto code path.
289
+ */
290
+ async function fallbackGenerate(secret: string): Promise<string> {
291
+ const random = new Uint8Array(32);
292
+ crypto.getRandomValues(random);
293
+ const randomPart = base64UrlEncode(random);
294
+ const sig = await hmacSignB64Url(randomPart, secret);
295
+ return `${randomPart}.${sig}`;
296
+ }
297
+
298
+ async function fallbackVerify(token: string, secret: string): Promise<boolean> {
299
+ const dotIdx = token.lastIndexOf(".");
300
+ if (dotIdx <= 0 || dotIdx === token.length - 1) return false;
301
+ const randomPart = token.slice(0, dotIdx);
302
+ const signature = token.slice(dotIdx + 1);
303
+ if (!randomPart || !signature) return false;
304
+ const expected = await hmacSignB64Url(randomPart, secret);
305
+ // Constant-time comparison on signatures.
306
+ return safeEqual(signature, expected);
307
+ }
308
+
309
+ async function hmacSignB64Url(data: string, secret: string): Promise<string> {
310
+ const encoder = new TextEncoder();
311
+ const key = await crypto.subtle.importKey(
312
+ "raw",
313
+ encoder.encode(secret),
314
+ { name: "HMAC", hash: "SHA-256" },
315
+ false,
316
+ ["sign"]
317
+ );
318
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
319
+ return base64UrlEncode(new Uint8Array(sig));
320
+ }
321
+
322
+ function base64UrlEncode(bytes: Uint8Array): string {
323
+ let binary = "";
324
+ for (let i = 0; i < bytes.length; i++) {
325
+ binary += String.fromCharCode(bytes[i]);
326
+ }
327
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
328
+ }