@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
package/README.md CHANGED
@@ -162,7 +162,7 @@ watcher.start();
162
162
  listPresets().forEach(p => console.log(p.name, p.description));
163
163
  ```
164
164
 
165
- ### Presets
165
+ ### Presets (6)
166
166
 
167
167
  | Preset | Layers | Use Case |
168
168
  |--------|--------|----------|
@@ -171,6 +171,7 @@ listPresets().forEach(p => console.log(p.name, p.description));
171
171
  | `clean` | api, application, domain, infra, shared | Backend |
172
172
  | `hexagonal` | adapters, ports, application, domain | DDD |
173
173
  | `atomic` | pages, templates, organisms, molecules, atoms | UI |
174
+ | `cqrs` | commands, queries, events, dto, application, domain | Event-sourced apps |
174
175
 
175
176
  ### AST-based Analysis
176
177
 
package/package.json CHANGED
@@ -1,18 +1,35 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.20.10",
3
+ "version": "0.22.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
+ "./auth": "./src/auth/index.ts",
11
+ "./auth/login": "./src/auth/login.ts",
12
+ "./auth/password": "./src/auth/password.ts",
13
+ "./auth/reset": "./src/auth/reset.ts",
14
+ "./auth/verification": "./src/auth/verification.ts",
10
15
  "./client": "./src/client/index.ts",
16
+ "./db": "./src/db/index.ts",
17
+ "./desktop": "./src/desktop/index.ts",
18
+ "./desktop/worker": "./src/desktop/worker.ts",
19
+ "./email": "./src/email/index.ts",
20
+ "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
11
21
  "./middleware": "./src/middleware/index.ts",
22
+ "./middleware/oauth": "./src/middleware/oauth/index.ts",
23
+ "./middleware/secure": "./src/middleware/secure/index.ts",
24
+ "./middleware/rate-limit": "./src/middleware/rate-limit/index.ts",
12
25
  "./testing": "./src/testing/index.ts",
13
26
  "./plugins": "./src/plugins/index.ts",
14
27
  "./error": "./src/error/index.ts",
28
+ "./id": "./src/id/index.ts",
15
29
  "./observability": "./src/observability/index.ts",
30
+ "./perf": "./src/perf/index.ts",
31
+ "./scheduler": "./src/scheduler/index.ts",
32
+ "./storage/s3": "./src/storage/s3/index.ts",
16
33
  "./bundler/prerender": "./src/bundler/prerender.ts",
17
34
  "./*": "./src/*"
18
35
  },
@@ -46,16 +63,24 @@
46
63
  "access": "public"
47
64
  },
48
65
  "engines": {
49
- "bun": ">=1.0.0"
66
+ "bun": ">=1.3.12"
50
67
  },
51
68
  "peerDependencies": {
52
69
  "react": "^19.0.0",
53
70
  "react-dom": "^19.0.0",
54
- "@tailwindcss/cli": ">=4.0.0"
71
+ "react-refresh": ">=0.18.0",
72
+ "@tailwindcss/cli": ">=4.0.0",
73
+ "webview-bun": "^2.4.0"
55
74
  },
56
75
  "peerDependenciesMeta": {
57
76
  "@tailwindcss/cli": {
58
77
  "optional": true
78
+ },
79
+ "react-refresh": {
80
+ "optional": true
81
+ },
82
+ "webview-bun": {
83
+ "optional": true
59
84
  }
60
85
  },
61
86
  "dependencies": {
@@ -0,0 +1,419 @@
1
+ /**
2
+ * @mandujs/core/auth/login tests
3
+ *
4
+ * Covers ergonomic login helpers that wrap Phase 2.3's session middleware.
5
+ * Fixture style mirrors `tests/middleware/session.test.ts` — real Request /
6
+ * Response objects, real `createCookieSessionStorage`, no mocks.
7
+ *
8
+ * What we verify:
9
+ * - `loginUser` writes userId + loggedAt + extras and commits in one shot
10
+ * - Custom keys are honored
11
+ * - Missing session middleware throws `AuthenticationError`
12
+ * - `logoutUser` emits expiring Set-Cookie and is idempotent
13
+ * - `currentUserId` / `loggedAt` are non-throwing read paths
14
+ * - End-to-end roundtrip through `ManduFilling`
15
+ * - Composition with `requireUser` via a `loadUser` beforeHandle bridge
16
+ * (executable documentation for the common real-world pattern)
17
+ */
18
+
19
+ import { describe, it, expect } from "bun:test";
20
+ import {
21
+ loginUser,
22
+ logoutUser,
23
+ currentUserId,
24
+ loggedAt,
25
+ } from "../login";
26
+ import { session } from "../../middleware/session";
27
+ import {
28
+ Session,
29
+ createCookieSessionStorage,
30
+ type SessionStorage,
31
+ } from "../../filling/session";
32
+ import { ManduContext } from "../../filling/context";
33
+ import { ManduFilling } from "../../filling/filling";
34
+ import {
35
+ AuthenticationError,
36
+ requireUser,
37
+ type BaseUser,
38
+ } from "../../filling/auth";
39
+
40
+ // ========== Helpers ==========
41
+
42
+ const SECRET = "login-helper-test-secret-32bytes!";
43
+
44
+ function makeReq(url: string, init: RequestInit & { cookie?: string } = {}): Request {
45
+ const { cookie, headers: rawHeaders, ...rest } = init;
46
+ const headers = new Headers(rawHeaders as HeadersInit | undefined);
47
+ if (cookie) headers.set("cookie", cookie);
48
+ return new Request(url, { ...rest, headers });
49
+ }
50
+
51
+ function makeCtx(req: Request): ManduContext {
52
+ return new ManduContext(req);
53
+ }
54
+
55
+ function makeStorage(): SessionStorage {
56
+ return createCookieSessionStorage({
57
+ cookie: { secrets: [SECRET] },
58
+ });
59
+ }
60
+
61
+ /** Extract the first Set-Cookie line matching `name=`. */
62
+ function readSetCookieLine(res: Response, name: string): string | null {
63
+ const headers = res.headers.getSetCookie?.() ?? [];
64
+ const needle = `${name}=`;
65
+ for (const line of headers) {
66
+ if (line.startsWith(needle)) return line;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** Extract just the raw (encoded) cookie value from a Set-Cookie line. */
72
+ function readSetCookieRawValue(res: Response, name: string): string | null {
73
+ const line = readSetCookieLine(res, name);
74
+ if (!line) return null;
75
+ const [nv] = line.split(";");
76
+ const eq = nv.indexOf("=");
77
+ if (eq <= 0) return null;
78
+ return nv.slice(eq + 1).trim();
79
+ }
80
+
81
+ // ========== loginUser ==========
82
+
83
+ describe("loginUser — stores userId + loggedAt and commits", () => {
84
+ it("writes userId + loggedAt and emits Set-Cookie via saveSession", async () => {
85
+ const storage = makeStorage();
86
+ const mw = session({ storage });
87
+ const ctx = makeCtx(makeReq("http://localhost/"));
88
+ await mw(ctx);
89
+
90
+ await loginUser(ctx, "user-123");
91
+
92
+ // In-memory session state is visible immediately.
93
+ const s = ctx.get<Session>("session")!;
94
+ expect(s.get<string>("userId")).toBe("user-123");
95
+ expect(typeof s.get<number>("loginAt")).toBe("number");
96
+
97
+ // Set-Cookie lands on the next response built from this ctx.
98
+ const res = ctx.ok({ ok: true });
99
+ const line = readSetCookieLine(res, "__session");
100
+ expect(line).toBeTruthy();
101
+ expect(line!).toContain("HttpOnly");
102
+ });
103
+
104
+ it("honors a custom userIdKey", async () => {
105
+ const storage = makeStorage();
106
+ const mw = session({ storage });
107
+ const ctx = makeCtx(makeReq("http://localhost/"));
108
+ await mw(ctx);
109
+
110
+ await loginUser(ctx, "u-42", { userIdKey: "uid" });
111
+
112
+ const s = ctx.get<Session>("session")!;
113
+ expect(s.get<string>("uid")).toBe("u-42");
114
+ // Default key is NOT populated when custom key is supplied.
115
+ expect(s.get<string>("userId")).toBeUndefined();
116
+ });
117
+
118
+ it("writes extras atomically with userId + loggedAt", async () => {
119
+ const storage = makeStorage();
120
+ const mw = session({ storage });
121
+ const ctx = makeCtx(makeReq("http://localhost/"));
122
+ await mw(ctx);
123
+
124
+ await loginUser(ctx, "u-1", {
125
+ extras: {
126
+ role: "admin",
127
+ tenantId: "acme",
128
+ remember: true,
129
+ seat: 7,
130
+ },
131
+ });
132
+
133
+ const s = ctx.get<Session>("session")!;
134
+ expect(s.get<string>("userId")).toBe("u-1");
135
+ expect(s.get<string>("role")).toBe("admin");
136
+ expect(s.get<string>("tenantId")).toBe("acme");
137
+ expect(s.get<boolean>("remember")).toBe(true);
138
+ expect(s.get<number>("seat")).toBe(7);
139
+ });
140
+
141
+ it("throws AuthenticationError when session middleware is not installed", async () => {
142
+ // No `session()` middleware — ctx has no attached session.
143
+ const ctx = makeCtx(makeReq("http://localhost/"));
144
+
145
+ let thrown: unknown = null;
146
+ try {
147
+ await loginUser(ctx, "u-1");
148
+ } catch (err) {
149
+ thrown = err;
150
+ }
151
+ expect(thrown).toBeInstanceOf(AuthenticationError);
152
+ expect((thrown as AuthenticationError).statusCode).toBe(401);
153
+ expect(String(thrown)).toContain("Session middleware not installed");
154
+ });
155
+
156
+ it("rejects empty userId with AuthenticationError (never silently stores '')", async () => {
157
+ const storage = makeStorage();
158
+ const mw = session({ storage });
159
+ const ctx = makeCtx(makeReq("http://localhost/"));
160
+ await mw(ctx);
161
+
162
+ await expect(loginUser(ctx, "")).rejects.toBeInstanceOf(AuthenticationError);
163
+ });
164
+ });
165
+
166
+ // ========== logoutUser ==========
167
+
168
+ describe("logoutUser — expiring cookie, idempotent", () => {
169
+ it("emits an expiring Set-Cookie (Max-Age=0)", async () => {
170
+ const storage = makeStorage();
171
+ const mw = session({ storage });
172
+ const ctx = makeCtx(makeReq("http://localhost/"));
173
+ await mw(ctx);
174
+ await loginUser(ctx, "u-1");
175
+
176
+ // Now log out on a fresh ctx with the just-issued cookie — but for a unit
177
+ // test we can call logoutUser on the same ctx; destroySession will emit a
178
+ // new Set-Cookie line. There will be multiple Set-Cookie lines for
179
+ // __session because the first loginUser already queued one; we're checking
180
+ // that at least one Max-Age=0 line is present.
181
+ await logoutUser(ctx);
182
+ const res = ctx.ok({ ok: true });
183
+
184
+ const headers = res.headers.getSetCookie?.() ?? [];
185
+ const sessionLines = headers.filter((l) => l.startsWith("__session="));
186
+ expect(sessionLines.length).toBeGreaterThan(0);
187
+ const hasExpiring = sessionLines.some((l) => l.includes("Max-Age=0"));
188
+ expect(hasExpiring).toBe(true);
189
+ });
190
+
191
+ it("is idempotent — two successive calls do not throw", async () => {
192
+ const storage = makeStorage();
193
+ const mw = session({ storage });
194
+ const ctx = makeCtx(makeReq("http://localhost/"));
195
+ await mw(ctx);
196
+
197
+ // No prior login — session is empty. Two back-to-back logout calls must
198
+ // not throw. This exercises the "already logged out" code path.
199
+ await logoutUser(ctx);
200
+ await logoutUser(ctx);
201
+
202
+ const res = ctx.ok({ ok: true });
203
+ const line = readSetCookieLine(res, "__session");
204
+ expect(line).toBeTruthy();
205
+ expect(line!).toContain("Max-Age=0");
206
+ });
207
+
208
+ it("throws AuthenticationError when session middleware is not installed", async () => {
209
+ const ctx = makeCtx(makeReq("http://localhost/"));
210
+ await expect(logoutUser(ctx)).rejects.toBeInstanceOf(AuthenticationError);
211
+ });
212
+ });
213
+
214
+ // ========== currentUserId ==========
215
+
216
+ describe("currentUserId — non-throwing reader", () => {
217
+ it("returns the userId after loginUser", async () => {
218
+ const storage = makeStorage();
219
+ const mw = session({ storage });
220
+ const ctx = makeCtx(makeReq("http://localhost/"));
221
+ await mw(ctx);
222
+
223
+ expect(currentUserId(ctx)).toBeNull();
224
+ await loginUser(ctx, "abc-999");
225
+ expect(currentUserId(ctx)).toBe("abc-999");
226
+ });
227
+
228
+ it("returns null WITHOUT throwing when session middleware is absent", () => {
229
+ const ctx = makeCtx(makeReq("http://localhost/"));
230
+ expect(() => currentUserId(ctx)).not.toThrow();
231
+ expect(currentUserId(ctx)).toBeNull();
232
+ });
233
+
234
+ it("returns null after logoutUser (in-memory wipe)", async () => {
235
+ const storage = makeStorage();
236
+ const mw = session({ storage });
237
+ const ctx = makeCtx(makeReq("http://localhost/"));
238
+ await mw(ctx);
239
+
240
+ await loginUser(ctx, "u-1");
241
+ expect(currentUserId(ctx)).toBe("u-1");
242
+
243
+ await logoutUser(ctx);
244
+ // destroySession wipes in-memory state → currentUserId sees no user.
245
+ expect(currentUserId(ctx)).toBeNull();
246
+ });
247
+
248
+ it("honors a custom userIdKey", async () => {
249
+ const storage = makeStorage();
250
+ const mw = session({ storage });
251
+ const ctx = makeCtx(makeReq("http://localhost/"));
252
+ await mw(ctx);
253
+
254
+ await loginUser(ctx, "u-custom", { userIdKey: "uid" });
255
+ expect(currentUserId(ctx)).toBeNull(); // default key empty
256
+ expect(currentUserId(ctx, { userIdKey: "uid" })).toBe("u-custom");
257
+ });
258
+ });
259
+
260
+ // ========== loggedAt ==========
261
+
262
+ describe("loggedAt — numeric timestamp reader", () => {
263
+ it("returns a timestamp close to Date.now() after login (within 1s)", async () => {
264
+ const storage = makeStorage();
265
+ const mw = session({ storage });
266
+ const ctx = makeCtx(makeReq("http://localhost/"));
267
+ await mw(ctx);
268
+
269
+ const before = Date.now();
270
+ await loginUser(ctx, "u-1");
271
+ const after = Date.now();
272
+
273
+ const ts = loggedAt(ctx);
274
+ expect(ts).not.toBeNull();
275
+ expect(typeof ts).toBe("number");
276
+ expect(ts!).toBeGreaterThanOrEqual(before);
277
+ expect(ts!).toBeLessThanOrEqual(after);
278
+ });
279
+
280
+ it("returns null when session middleware is absent", () => {
281
+ const ctx = makeCtx(makeReq("http://localhost/"));
282
+ expect(loggedAt(ctx)).toBeNull();
283
+ });
284
+ });
285
+
286
+ // ========== End-to-end roundtrip through ManduFilling ==========
287
+
288
+ describe("roundtrip — login → cookie → read → logout", () => {
289
+ it("completes a full login/read/logout flow across three requests", async () => {
290
+ const storage = makeStorage();
291
+
292
+ // Single filling pipeline handles all three paths via ?action=.
293
+ const filling = new ManduFilling()
294
+ .use(session({ storage }))
295
+ .get(async (ctx) => {
296
+ const action = new URL(ctx.request.url).searchParams.get("action");
297
+ if (action === "login") {
298
+ await loginUser(ctx, "roundtrip-user");
299
+ return ctx.ok({ stage: "login" });
300
+ }
301
+ if (action === "logout") {
302
+ await logoutUser(ctx);
303
+ return ctx.ok({ stage: "logout" });
304
+ }
305
+ // Default: read side.
306
+ return ctx.ok({ uid: currentUserId(ctx) });
307
+ });
308
+
309
+ // 1. Login — capture the Set-Cookie the browser would keep.
310
+ const loginRes = await filling.handle(makeReq("http://localhost/?action=login"));
311
+ expect(loginRes.status).toBe(200);
312
+ const cookieLine = readSetCookieLine(loginRes, "__session");
313
+ expect(cookieLine).toBeTruthy();
314
+ const cookieValue = readSetCookieRawValue(loginRes, "__session");
315
+ expect(cookieValue).toBeTruthy();
316
+
317
+ // 2. Read — re-attach the issued cookie and verify currentUserId finds
318
+ // the id we stored.
319
+ const readRes = await filling.handle(
320
+ makeReq("http://localhost/", { cookie: `__session=${cookieValue}` }),
321
+ );
322
+ expect(readRes.status).toBe(200);
323
+ const readBody = (await readRes.json()) as { uid: string | null };
324
+ expect(readBody.uid).toBe("roundtrip-user");
325
+
326
+ // 3. Logout — issues expiring cookie; subsequent read with *that* cookie
327
+ // returns null userId because the client would drop the cookie on
328
+ // Max-Age=0. We simulate this by not passing any cookie on the fourth
329
+ // request (emulating what the browser does after receiving the
330
+ // expiring Set-Cookie).
331
+ const logoutRes = await filling.handle(
332
+ makeReq("http://localhost/?action=logout", { cookie: `__session=${cookieValue}` }),
333
+ );
334
+ expect(logoutRes.status).toBe(200);
335
+ const logoutLine = readSetCookieLine(logoutRes, "__session");
336
+ expect(logoutLine).toBeTruthy();
337
+ expect(logoutLine!).toContain("Max-Age=0");
338
+
339
+ // 4. Post-logout read with NO cookie — currentUserId is null.
340
+ const postLogoutRes = await filling.handle(makeReq("http://localhost/"));
341
+ const postLogoutBody = (await postLogoutRes.json()) as { uid: string | null };
342
+ expect(postLogoutBody.uid).toBeNull();
343
+ });
344
+ });
345
+
346
+ // ========== Composition with requireUser from filling/auth ==========
347
+
348
+ describe("composition with requireUser — the loadUser bridge pattern", () => {
349
+ it("a loadUser beforeHandle bridges session.userId → ctx.set('user', ...) for requireUser", async () => {
350
+ // This is the common real-world pattern worth documenting as a test:
351
+ //
352
+ // - `loginUser` writes `userId` to the SESSION (persisted across
353
+ // requests via cookie).
354
+ // - `requireUser` reads the USER OBJECT from ctx.store at key "user"
355
+ // (a request-scoped store, wiped every request).
356
+ // - A tiny middleware placed AFTER `session()` reads the session
357
+ // userId, fetches the user record from your data layer, and calls
358
+ // `ctx.set("user", user)`. That's the bridge.
359
+
360
+ interface User extends BaseUser {
361
+ id: string;
362
+ name: string;
363
+ }
364
+
365
+ // Fake user store.
366
+ const users = new Map<string, User>([
367
+ ["u-1", { id: "u-1", name: "Alice" }],
368
+ ["u-2", { id: "u-2", name: "Bob" }],
369
+ ]);
370
+
371
+ const storage = makeStorage();
372
+
373
+ const filling = new ManduFilling()
374
+ .use(session({ storage }))
375
+ // ⭐️ The bridge: after session() installs the Session, hydrate the
376
+ // request-scoped `user` from the session's userId. Placed in
377
+ // beforeHandle so ALL subsequent handler code can rely on
378
+ // `requireUser(ctx)`.
379
+ .beforeHandle(async (ctx) => {
380
+ const uid = currentUserId(ctx);
381
+ if (uid) {
382
+ const user = users.get(uid);
383
+ if (user) ctx.set("user", user);
384
+ }
385
+ })
386
+ .get(async (ctx) => {
387
+ const action = new URL(ctx.request.url).searchParams.get("action");
388
+ if (action === "login") {
389
+ await loginUser(ctx, "u-1");
390
+ return ctx.ok({ stage: "login" });
391
+ }
392
+ // requireUser reads ctx.get("user") — the bridge populated it above
393
+ // IFF the session had a valid userId.
394
+ const user = requireUser<User>(ctx);
395
+ return ctx.ok({ id: user.id, name: user.name });
396
+ });
397
+
398
+ // 1. Login issues the cookie.
399
+ const loginRes = await filling.handle(makeReq("http://localhost/?action=login"));
400
+ expect(loginRes.status).toBe(200);
401
+ const cookieValue = readSetCookieRawValue(loginRes, "__session");
402
+ expect(cookieValue).toBeTruthy();
403
+
404
+ // 2. Second request re-attaches cookie → bridge hydrates → requireUser
405
+ // returns the full user record.
406
+ const res = await filling.handle(
407
+ makeReq("http://localhost/", { cookie: `__session=${cookieValue}` }),
408
+ );
409
+ expect(res.status).toBe(200);
410
+ const body = (await res.json()) as { id: string; name: string };
411
+ expect(body.id).toBe("u-1");
412
+ expect(body.name).toBe("Alice");
413
+
414
+ // 3. Third request with NO cookie → no session userId → bridge does
415
+ // nothing → requireUser throws → filling maps to 401.
416
+ const unauthRes = await filling.handle(makeReq("http://localhost/"));
417
+ expect(unauthRes.status).toBe(401);
418
+ });
419
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @mandujs/core/auth/password tests
3
+ *
4
+ * These tests exercise real argon2id and bcrypt hashing through `Bun.password`.
5
+ * They are CPU-bound (~50-200ms per hash op on laptop hardware); the suite
6
+ * uses the minimum cost parameters Bun accepts whenever the test does not
7
+ * care about actual KDF strength. Total runtime ~2-5s is expected.
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import { hashPassword, verifyPassword } from "../password";
12
+
13
+ // Argon2 hashes start with "$argon2id$", "$argon2d$", or "$argon2i$".
14
+ const ARGON2ID_PREFIX = "$argon2id$";
15
+ // bcrypt hashes use $2a$/$2b$/$2y$ prefixes; Bun emits $2b$.
16
+ const BCRYPT_PREFIX_RE = /^\$2[aby]\$/;
17
+
18
+ // Minimum argon2 cost params — keep tests fast without changing behavior.
19
+ const FAST_ARGON2 = {
20
+ algorithm: "argon2id",
21
+ memoryCost: 4,
22
+ timeCost: 2,
23
+ } as const;
24
+
25
+ describe("@mandujs/core/auth/password — hashPassword", () => {
26
+ it("produces an argon2id hash by default", async () => {
27
+ const h = await hashPassword("hunter2", FAST_ARGON2);
28
+ expect(h.startsWith(ARGON2ID_PREFIX)).toBe(true);
29
+ });
30
+
31
+ it("produces a bcrypt hash when algorithm=bcrypt is requested", async () => {
32
+ const h = await hashPassword("hunter2", { algorithm: "bcrypt", cost: 4 });
33
+ expect(h).toMatch(BCRYPT_PREFIX_RE);
34
+ });
35
+
36
+ it("throws on empty plaintext", async () => {
37
+ await expect(hashPassword("", FAST_ARGON2)).rejects.toThrow(
38
+ /non-empty string/,
39
+ );
40
+ });
41
+
42
+ it("throws when bcrypt input exceeds 72 bytes", async () => {
43
+ // 73 ASCII bytes (each char = 1 UTF-8 byte).
44
+ const tooLong = "a".repeat(73);
45
+ await expect(
46
+ hashPassword(tooLong, { algorithm: "bcrypt", cost: 4 }),
47
+ ).rejects.toThrow(/72-byte limit/);
48
+ });
49
+
50
+ it("accepts >72-byte input when using argon2id (bcrypt-only limit)", async () => {
51
+ const longButFine = "a".repeat(100);
52
+ const h = await hashPassword(longButFine, FAST_ARGON2);
53
+ expect(h.startsWith(ARGON2ID_PREFIX)).toBe(true);
54
+ });
55
+
56
+ it("counts UTF-8 bytes (not code points) when enforcing the bcrypt limit", async () => {
57
+ // "é" is 2 bytes in UTF-8 — 40 of them = 80 bytes, over the 72 limit.
58
+ const multibyte = "é".repeat(40);
59
+ await expect(
60
+ hashPassword(multibyte, { algorithm: "bcrypt", cost: 4 }),
61
+ ).rejects.toThrow(/72-byte limit/);
62
+ });
63
+
64
+ it("honors non-default argon2 timeCost via option pass-through", async () => {
65
+ // Bun embeds the cost parameters in the hash string:
66
+ // $argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=1$<salt>$<hash>
67
+ // We assert the timeCost is preserved end-to-end.
68
+ const h = await hashPassword("same-password", {
69
+ algorithm: "argon2id",
70
+ memoryCost: 4,
71
+ timeCost: 3,
72
+ });
73
+ expect(h).toMatch(/\$argon2id\$v=\d+\$m=4,t=3,p=\d+\$/);
74
+ });
75
+ });
76
+
77
+ describe("@mandujs/core/auth/password — verifyPassword", () => {
78
+ it("returns true for a matching argon2id roundtrip", async () => {
79
+ const h = await hashPassword("correct-horse-battery-staple", FAST_ARGON2);
80
+ const ok = await verifyPassword("correct-horse-battery-staple", h);
81
+ expect(ok).toBe(true);
82
+ });
83
+
84
+ it("returns true for a matching bcrypt roundtrip", async () => {
85
+ const h = await hashPassword("correct-horse-battery-staple", {
86
+ algorithm: "bcrypt",
87
+ cost: 4,
88
+ });
89
+ const ok = await verifyPassword("correct-horse-battery-staple", h);
90
+ expect(ok).toBe(true);
91
+ });
92
+
93
+ it("returns false for wrong plaintext", async () => {
94
+ const h = await hashPassword("correct", FAST_ARGON2);
95
+ expect(await verifyPassword("incorrect", h)).toBe(false);
96
+ });
97
+
98
+ it("returns false for a tampered hash (one char mutated)", async () => {
99
+ const h = await hashPassword("correct", FAST_ARGON2);
100
+ // Flip the last character of the base64 hash segment.
101
+ const last = h.charAt(h.length - 1);
102
+ const replacement = last === "A" ? "B" : "A";
103
+ const tampered = h.slice(0, -1) + replacement;
104
+ expect(tampered).not.toBe(h);
105
+ expect(await verifyPassword("correct", tampered)).toBe(false);
106
+ });
107
+
108
+ it("returns false (never throws) for a malformed hash string", async () => {
109
+ // Bun.password.verify throws on unparseable hashes; our wrapper collapses
110
+ // every failure mode to `false` so login handlers stay branchless.
111
+ expect(await verifyPassword("anything", "not-a-hash")).toBe(false);
112
+ });
113
+
114
+ it("returns false for empty plaintext", async () => {
115
+ const h = await hashPassword("correct", FAST_ARGON2);
116
+ expect(await verifyPassword("", h)).toBe(false);
117
+ });
118
+
119
+ it("returns false for empty hash", async () => {
120
+ expect(await verifyPassword("correct", "")).toBe(false);
121
+ });
122
+ });