@mandujs/core 0.41.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,296 +1,296 @@
1
- /**
2
- * @mandujs/core/auth/reset — flow tests.
3
- *
4
- * Cover `createPasswordReset` end-to-end: mint + send via in-memory email,
5
- * consume + onReset via `hashPassword`. The KDF hot path is CPU-bound —
6
- * we pass minimal argon2id cost parameters everywhere to keep wall-clock
7
- * time short.
8
- */
9
-
10
- import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
11
-
12
- import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
13
- import { verifyPassword } from "../password";
14
- import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
15
- import { createPasswordReset } from "../reset";
16
-
17
- // ─── Gate on Bun.SQL + CryptoHasher + Bun.password ──────────────────────────
18
-
19
- const hasBun = (() => {
20
- const g = globalThis as unknown as {
21
- Bun?: { SQL?: unknown; CryptoHasher?: unknown; password?: unknown };
22
- };
23
- return (
24
- typeof g.Bun?.SQL === "function" &&
25
- typeof g.Bun?.CryptoHasher === "function" &&
26
- g.Bun?.password !== undefined
27
- );
28
- })();
29
- const describeIfBun = hasBun ? describe : describe.skip;
30
-
31
- // ─── Fixtures ───────────────────────────────────────────────────────────────
32
-
33
- const SECRET = "reset-flow-test-secret-32-bytes-or-more!!";
34
- const URL_TEMPLATE = "https://app.example.com/reset?token={token}";
35
- const FROM = "noreply@example.com";
36
- const ARGON2ID_PREFIX = "$argon2id$";
37
- const FAST_ARGON2 = { algorithm: "argon2id", memoryCost: 4, timeCost: 2 } as const;
38
-
39
- interface Fixture {
40
- store: AuthTokenStore;
41
- sender: MemoryEmailSender;
42
- onResetCalls: Array<{ userId: string; newHash: string }>;
43
- }
44
-
45
- function makeFixture(): Fixture {
46
- return {
47
- store: createAuthTokenStore({
48
- secret: SECRET,
49
- dbPath: ":memory:",
50
- gcSchedule: false,
51
- }),
52
- sender: createMemoryEmailSender(),
53
- onResetCalls: [],
54
- };
55
- }
56
-
57
- describeIfBun("@mandujs/core/auth/reset — createPasswordReset", () => {
58
- let fx: Fixture;
59
-
60
- beforeEach(() => {
61
- fx = makeFixture();
62
- });
63
-
64
- afterEach(async () => {
65
- await fx.store.close();
66
- });
67
-
68
- it("send() mints a reset token and dispatches an email with the link", async () => {
69
- const renderEmail = mock(({ url }: { url: string }) => ({
70
- subject: "Reset your password",
71
- html: `<a href="${url}">reset</a>`,
72
- }));
73
- const reset = createPasswordReset({
74
- store: fx.store,
75
- sender: fx.sender,
76
- fromAddress: FROM,
77
- resetUrlTemplate: URL_TEMPLATE,
78
- renderEmail,
79
- passwordOptions: FAST_ARGON2,
80
- onReset: async (args) => {
81
- fx.onResetCalls.push(args);
82
- },
83
- });
84
-
85
- await reset.send("u-1", "alice@example.com");
86
- expect(fx.sender.sent).toHaveLength(1);
87
- const msg = fx.sender.sent[0]!;
88
- expect(msg.from).toBe(FROM);
89
- expect(msg.to).toBe("alice@example.com");
90
- expect(msg.subject).toBe("Reset your password");
91
- expect(msg.html).toContain("https://app.example.com/reset?token=");
92
- });
93
-
94
- it("consume(validToken, newPassword) returns { userId } and calls onReset with an argon2id hash", async () => {
95
- const reset = createPasswordReset({
96
- store: fx.store,
97
- sender: fx.sender,
98
- fromAddress: FROM,
99
- resetUrlTemplate: URL_TEMPLATE,
100
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
101
- passwordOptions: FAST_ARGON2,
102
- onReset: async (args) => {
103
- fx.onResetCalls.push(args);
104
- },
105
- });
106
-
107
- await reset.send("u-2", "bob@example.com");
108
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
109
-
110
- const result = await reset.consume(token, "newPass123");
111
- expect(result).toEqual({ userId: "u-2" });
112
- expect(fx.onResetCalls).toHaveLength(1);
113
- expect(fx.onResetCalls[0]!.userId).toBe("u-2");
114
- expect(fx.onResetCalls[0]!.newHash.startsWith(ARGON2ID_PREFIX)).toBe(true);
115
- });
116
-
117
- it("empty newPassword throws TypeError without consuming the token", async () => {
118
- const reset = createPasswordReset({
119
- store: fx.store,
120
- sender: fx.sender,
121
- fromAddress: FROM,
122
- resetUrlTemplate: URL_TEMPLATE,
123
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
124
- passwordOptions: FAST_ARGON2,
125
- onReset: async () => {},
126
- });
127
-
128
- await reset.send("u-3", "carol@example.com");
129
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
130
-
131
- await expect(reset.consume(token, "")).rejects.toThrow(/non-empty/);
132
- // Token was NOT consumed — a real call still works.
133
- const ok = await reset.consume(token, "validPass123");
134
- expect(ok).toEqual({ userId: "u-3" });
135
- });
136
-
137
- it("onReset receives the hash, not the plaintext password", async () => {
138
- let captured: { userId: string; newHash: string } | null = null;
139
- const reset = createPasswordReset({
140
- store: fx.store,
141
- sender: fx.sender,
142
- fromAddress: FROM,
143
- resetUrlTemplate: URL_TEMPLATE,
144
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
145
- passwordOptions: FAST_ARGON2,
146
- onReset: async (args) => {
147
- captured = args;
148
- },
149
- });
150
- await reset.send("u-4", "dave@example.com");
151
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
152
-
153
- const plaintext = "totally-secret-password";
154
- await reset.consume(token, plaintext);
155
-
156
- expect(captured).not.toBeNull();
157
- const hash = captured!.newHash;
158
- // The plaintext must NEVER appear in the hash payload.
159
- expect(hash).not.toContain(plaintext);
160
- // verifyPassword round-trips correctly against the hash.
161
- expect(await verifyPassword(plaintext, hash)).toBe(true);
162
- expect(await verifyPassword("wrong-password", hash)).toBe(false);
163
- });
164
-
165
- it("reusing a consumed token returns null and onReset fires exactly once", async () => {
166
- const reset = createPasswordReset({
167
- store: fx.store,
168
- sender: fx.sender,
169
- fromAddress: FROM,
170
- resetUrlTemplate: URL_TEMPLATE,
171
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
172
- passwordOptions: FAST_ARGON2,
173
- onReset: async (args) => {
174
- fx.onResetCalls.push(args);
175
- },
176
- });
177
- await reset.send("u-5", "eve@example.com");
178
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
179
-
180
- const first = await reset.consume(token, "first-pass");
181
- expect(first).not.toBeNull();
182
- const second = await reset.consume(token, "second-pass");
183
- expect(second).toBeNull();
184
- expect(fx.onResetCalls).toHaveLength(1);
185
- });
186
-
187
- it("reset does NOT auto-login — we don't touch sessions", async () => {
188
- // This test guards the contract: the returned shape is `{ userId }`
189
- // only — there are no cookie side-effects, no session helpers called.
190
- // We construct the flow with NO session storage at all and expect
191
- // consume() to still succeed.
192
- const reset = createPasswordReset({
193
- store: fx.store,
194
- sender: fx.sender,
195
- fromAddress: FROM,
196
- resetUrlTemplate: URL_TEMPLATE,
197
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
198
- passwordOptions: FAST_ARGON2,
199
- onReset: async () => {},
200
- });
201
- await reset.send("u-6", "frank@example.com");
202
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
203
-
204
- const result = await reset.consume(token, "fresh-pass");
205
- expect(result).toEqual({ userId: "u-6" });
206
- // Contract: ReturnType<consume> is never {userId, cookie, session, …}.
207
- // The runtime shape must match — if a future refactor adds fields we
208
- // need to revisit the doc + this guard.
209
- expect(Object.keys(result!).sort()).toEqual(["userId"]);
210
- });
211
-
212
- it("hashPassword is invoked with the raw plaintext (argon2id default)", async () => {
213
- // Black-box: the only way to observe "hashPassword was called with
214
- // this plaintext" without reaching past the module boundary is to
215
- // verify the emitted hash authenticates the plaintext.
216
- let newHash: string | null = null;
217
- const reset = createPasswordReset({
218
- store: fx.store,
219
- sender: fx.sender,
220
- fromAddress: FROM,
221
- resetUrlTemplate: URL_TEMPLATE,
222
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
223
- passwordOptions: FAST_ARGON2,
224
- onReset: async (args) => {
225
- newHash = args.newHash;
226
- },
227
- });
228
- await reset.send("u-7", "grace@example.com");
229
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
230
- await reset.consume(token, "plaintext-checked");
231
-
232
- expect(newHash).not.toBeNull();
233
- expect(newHash!.startsWith(ARGON2ID_PREFIX)).toBe(true);
234
- expect(await verifyPassword("plaintext-checked", newHash!)).toBe(true);
235
- });
236
-
237
- it("bcrypt's 72-byte limit: hashPassword error propagates (token is already spent)", async () => {
238
- const reset = createPasswordReset({
239
- store: fx.store,
240
- sender: fx.sender,
241
- fromAddress: FROM,
242
- resetUrlTemplate: URL_TEMPLATE,
243
- renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
244
- // Force bcrypt so the 72-byte guard in hashPassword fires.
245
- passwordOptions: { algorithm: "bcrypt", cost: 4 },
246
- onReset: async () => {
247
- throw new Error("onReset should not be invoked when hashPassword throws");
248
- },
249
- });
250
- await reset.send("u-8", "heidi@example.com");
251
- const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
252
-
253
- const tooLong = "a".repeat(100); // 100 UTF-8 bytes, over the 72 limit.
254
- await expect(reset.consume(token, tooLong)).rejects.toThrow(/72-byte limit/);
255
- // Token IS consumed by the time hashPassword throws — a retry returns null.
256
- const retry = await reset.consume(token, "shortPass");
257
- expect(retry).toBeNull();
258
- });
259
-
260
- it("resetUrlTemplate without {token} throws at create time", () => {
261
- expect(() =>
262
- createPasswordReset({
263
- store: fx.store,
264
- sender: fx.sender,
265
- fromAddress: FROM,
266
- resetUrlTemplate: "https://app.example.com/reset?nope=1",
267
- renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
268
- passwordOptions: FAST_ARGON2,
269
- onReset: async () => {},
270
- }),
271
- ).toThrow(/\{token\}/);
272
- });
273
-
274
- it("empty fromAddress throws at create time", () => {
275
- expect(() =>
276
- createPasswordReset({
277
- store: fx.store,
278
- sender: fx.sender,
279
- fromAddress: "",
280
- resetUrlTemplate: URL_TEMPLATE,
281
- renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
282
- passwordOptions: FAST_ARGON2,
283
- onReset: async () => {},
284
- }),
285
- ).toThrow(/fromAddress/);
286
- });
287
- });
288
-
289
- // ─── Helpers ────────────────────────────────────────────────────────────────
290
-
291
- function extractTokenFromUrl(html: string): string | null {
292
- const hrefMatch = /href="([^"]+)"/.exec(html);
293
- const url = hrefMatch ? hrefMatch[1]! : html;
294
- const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
295
- return tokenMatch ? tokenMatch[1]! : null;
296
- }
1
+ /**
2
+ * @mandujs/core/auth/reset — flow tests.
3
+ *
4
+ * Cover `createPasswordReset` end-to-end: mint + send via in-memory email,
5
+ * consume + onReset via `hashPassword`. The KDF hot path is CPU-bound —
6
+ * we pass minimal argon2id cost parameters everywhere to keep wall-clock
7
+ * time short.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
11
+
12
+ import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
13
+ import { verifyPassword } from "../password";
14
+ import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
15
+ import { createPasswordReset } from "../reset";
16
+
17
+ // ─── Gate on Bun.SQL + CryptoHasher + Bun.password ──────────────────────────
18
+
19
+ const hasBun = (() => {
20
+ const g = globalThis as unknown as {
21
+ Bun?: { SQL?: unknown; CryptoHasher?: unknown; password?: unknown };
22
+ };
23
+ return (
24
+ typeof g.Bun?.SQL === "function" &&
25
+ typeof g.Bun?.CryptoHasher === "function" &&
26
+ g.Bun?.password !== undefined
27
+ );
28
+ })();
29
+ const describeIfBun = hasBun ? describe : describe.skip;
30
+
31
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
32
+
33
+ const SECRET = "reset-flow-test-secret-32-bytes-or-more!!";
34
+ const URL_TEMPLATE = "https://app.example.com/reset?token={token}";
35
+ const FROM = "noreply@example.com";
36
+ const ARGON2ID_PREFIX = "$argon2id$";
37
+ const FAST_ARGON2 = { algorithm: "argon2id", memoryCost: 4, timeCost: 2 } as const;
38
+
39
+ interface Fixture {
40
+ store: AuthTokenStore;
41
+ sender: MemoryEmailSender;
42
+ onResetCalls: Array<{ userId: string; newHash: string }>;
43
+ }
44
+
45
+ function makeFixture(): Fixture {
46
+ return {
47
+ store: createAuthTokenStore({
48
+ secret: SECRET,
49
+ dbPath: ":memory:",
50
+ gcSchedule: false,
51
+ }),
52
+ sender: createMemoryEmailSender(),
53
+ onResetCalls: [],
54
+ };
55
+ }
56
+
57
+ describeIfBun("@mandujs/core/auth/reset — createPasswordReset", () => {
58
+ let fx: Fixture;
59
+
60
+ beforeEach(() => {
61
+ fx = makeFixture();
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await fx.store.close();
66
+ });
67
+
68
+ it("send() mints a reset token and dispatches an email with the link", async () => {
69
+ const renderEmail = mock(({ url }: { url: string }) => ({
70
+ subject: "Reset your password",
71
+ html: `<a href="${url}">reset</a>`,
72
+ }));
73
+ const reset = createPasswordReset({
74
+ store: fx.store,
75
+ sender: fx.sender,
76
+ fromAddress: FROM,
77
+ resetUrlTemplate: URL_TEMPLATE,
78
+ renderEmail,
79
+ passwordOptions: FAST_ARGON2,
80
+ onReset: async (args) => {
81
+ fx.onResetCalls.push(args);
82
+ },
83
+ });
84
+
85
+ await reset.send("u-1", "alice@example.com");
86
+ expect(fx.sender.sent).toHaveLength(1);
87
+ const msg = fx.sender.sent[0]!;
88
+ expect(msg.from).toBe(FROM);
89
+ expect(msg.to).toBe("alice@example.com");
90
+ expect(msg.subject).toBe("Reset your password");
91
+ expect(msg.html).toContain("https://app.example.com/reset?token=");
92
+ });
93
+
94
+ it("consume(validToken, newPassword) returns { userId } and calls onReset with an argon2id hash", async () => {
95
+ const reset = createPasswordReset({
96
+ store: fx.store,
97
+ sender: fx.sender,
98
+ fromAddress: FROM,
99
+ resetUrlTemplate: URL_TEMPLATE,
100
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
101
+ passwordOptions: FAST_ARGON2,
102
+ onReset: async (args) => {
103
+ fx.onResetCalls.push(args);
104
+ },
105
+ });
106
+
107
+ await reset.send("u-2", "bob@example.com");
108
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
109
+
110
+ const result = await reset.consume(token, "newPass123");
111
+ expect(result).toEqual({ userId: "u-2" });
112
+ expect(fx.onResetCalls).toHaveLength(1);
113
+ expect(fx.onResetCalls[0]!.userId).toBe("u-2");
114
+ expect(fx.onResetCalls[0]!.newHash.startsWith(ARGON2ID_PREFIX)).toBe(true);
115
+ });
116
+
117
+ it("empty newPassword throws TypeError without consuming the token", async () => {
118
+ const reset = createPasswordReset({
119
+ store: fx.store,
120
+ sender: fx.sender,
121
+ fromAddress: FROM,
122
+ resetUrlTemplate: URL_TEMPLATE,
123
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
124
+ passwordOptions: FAST_ARGON2,
125
+ onReset: async () => {},
126
+ });
127
+
128
+ await reset.send("u-3", "carol@example.com");
129
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
130
+
131
+ await expect(reset.consume(token, "")).rejects.toThrow(/non-empty/);
132
+ // Token was NOT consumed — a real call still works.
133
+ const ok = await reset.consume(token, "validPass123");
134
+ expect(ok).toEqual({ userId: "u-3" });
135
+ });
136
+
137
+ it("onReset receives the hash, not the plaintext password", async () => {
138
+ let captured: { userId: string; newHash: string } | null = null;
139
+ const reset = createPasswordReset({
140
+ store: fx.store,
141
+ sender: fx.sender,
142
+ fromAddress: FROM,
143
+ resetUrlTemplate: URL_TEMPLATE,
144
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
145
+ passwordOptions: FAST_ARGON2,
146
+ onReset: async (args) => {
147
+ captured = args;
148
+ },
149
+ });
150
+ await reset.send("u-4", "dave@example.com");
151
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
152
+
153
+ const plaintext = "totally-secret-password";
154
+ await reset.consume(token, plaintext);
155
+
156
+ expect(captured).not.toBeNull();
157
+ const hash = captured!.newHash;
158
+ // The plaintext must NEVER appear in the hash payload.
159
+ expect(hash).not.toContain(plaintext);
160
+ // verifyPassword round-trips correctly against the hash.
161
+ expect(await verifyPassword(plaintext, hash)).toBe(true);
162
+ expect(await verifyPassword("wrong-password", hash)).toBe(false);
163
+ });
164
+
165
+ it("reusing a consumed token returns null and onReset fires exactly once", async () => {
166
+ const reset = createPasswordReset({
167
+ store: fx.store,
168
+ sender: fx.sender,
169
+ fromAddress: FROM,
170
+ resetUrlTemplate: URL_TEMPLATE,
171
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
172
+ passwordOptions: FAST_ARGON2,
173
+ onReset: async (args) => {
174
+ fx.onResetCalls.push(args);
175
+ },
176
+ });
177
+ await reset.send("u-5", "eve@example.com");
178
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
179
+
180
+ const first = await reset.consume(token, "first-pass");
181
+ expect(first).not.toBeNull();
182
+ const second = await reset.consume(token, "second-pass");
183
+ expect(second).toBeNull();
184
+ expect(fx.onResetCalls).toHaveLength(1);
185
+ });
186
+
187
+ it("reset does NOT auto-login — we don't touch sessions", async () => {
188
+ // This test guards the contract: the returned shape is `{ userId }`
189
+ // only — there are no cookie side-effects, no session helpers called.
190
+ // We construct the flow with NO session storage at all and expect
191
+ // consume() to still succeed.
192
+ const reset = createPasswordReset({
193
+ store: fx.store,
194
+ sender: fx.sender,
195
+ fromAddress: FROM,
196
+ resetUrlTemplate: URL_TEMPLATE,
197
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
198
+ passwordOptions: FAST_ARGON2,
199
+ onReset: async () => {},
200
+ });
201
+ await reset.send("u-6", "frank@example.com");
202
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
203
+
204
+ const result = await reset.consume(token, "fresh-pass");
205
+ expect(result).toEqual({ userId: "u-6" });
206
+ // Contract: ReturnType<consume> is never {userId, cookie, session, …}.
207
+ // The runtime shape must match — if a future refactor adds fields we
208
+ // need to revisit the doc + this guard.
209
+ expect(Object.keys(result!).sort()).toEqual(["userId"]);
210
+ });
211
+
212
+ it("hashPassword is invoked with the raw plaintext (argon2id default)", async () => {
213
+ // Black-box: the only way to observe "hashPassword was called with
214
+ // this plaintext" without reaching past the module boundary is to
215
+ // verify the emitted hash authenticates the plaintext.
216
+ let newHash: string | null = null;
217
+ const reset = createPasswordReset({
218
+ store: fx.store,
219
+ sender: fx.sender,
220
+ fromAddress: FROM,
221
+ resetUrlTemplate: URL_TEMPLATE,
222
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
223
+ passwordOptions: FAST_ARGON2,
224
+ onReset: async (args) => {
225
+ newHash = args.newHash;
226
+ },
227
+ });
228
+ await reset.send("u-7", "grace@example.com");
229
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
230
+ await reset.consume(token, "plaintext-checked");
231
+
232
+ expect(newHash).not.toBeNull();
233
+ expect(newHash!.startsWith(ARGON2ID_PREFIX)).toBe(true);
234
+ expect(await verifyPassword("plaintext-checked", newHash!)).toBe(true);
235
+ });
236
+
237
+ it("bcrypt's 72-byte limit: hashPassword error propagates (token is already spent)", async () => {
238
+ const reset = createPasswordReset({
239
+ store: fx.store,
240
+ sender: fx.sender,
241
+ fromAddress: FROM,
242
+ resetUrlTemplate: URL_TEMPLATE,
243
+ renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
244
+ // Force bcrypt so the 72-byte guard in hashPassword fires.
245
+ passwordOptions: { algorithm: "bcrypt", cost: 4 },
246
+ onReset: async () => {
247
+ throw new Error("onReset should not be invoked when hashPassword throws");
248
+ },
249
+ });
250
+ await reset.send("u-8", "heidi@example.com");
251
+ const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
252
+
253
+ const tooLong = "a".repeat(100); // 100 UTF-8 bytes, over the 72 limit.
254
+ await expect(reset.consume(token, tooLong)).rejects.toThrow(/72-byte limit/);
255
+ // Token IS consumed by the time hashPassword throws — a retry returns null.
256
+ const retry = await reset.consume(token, "shortPass");
257
+ expect(retry).toBeNull();
258
+ });
259
+
260
+ it("resetUrlTemplate without {token} throws at create time", () => {
261
+ expect(() =>
262
+ createPasswordReset({
263
+ store: fx.store,
264
+ sender: fx.sender,
265
+ fromAddress: FROM,
266
+ resetUrlTemplate: "https://app.example.com/reset?nope=1",
267
+ renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
268
+ passwordOptions: FAST_ARGON2,
269
+ onReset: async () => {},
270
+ }),
271
+ ).toThrow(/\{token\}/);
272
+ });
273
+
274
+ it("empty fromAddress throws at create time", () => {
275
+ expect(() =>
276
+ createPasswordReset({
277
+ store: fx.store,
278
+ sender: fx.sender,
279
+ fromAddress: "",
280
+ resetUrlTemplate: URL_TEMPLATE,
281
+ renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
282
+ passwordOptions: FAST_ARGON2,
283
+ onReset: async () => {},
284
+ }),
285
+ ).toThrow(/fromAddress/);
286
+ });
287
+ });
288
+
289
+ // ─── Helpers ────────────────────────────────────────────────────────────────
290
+
291
+ function extractTokenFromUrl(html: string): string | null {
292
+ const hrefMatch = /href="([^"]+)"/.exec(html);
293
+ const url = hrefMatch ? hrefMatch[1]! : html;
294
+ const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
295
+ return tokenMatch ? tokenMatch[1]! : null;
296
+ }