@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,419 +1,420 @@
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 throwsfilling maps to 401.
416
- const unauthRes = await filling.handle(makeReq("http://localhost/"));
417
- expect(unauthRes.status).toBe(401);
418
- });
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 type {
28
+ Session} from "../../filling/session";
29
+ import {
30
+ createCookieSessionStorage,
31
+ type SessionStorage,
32
+ } from "../../filling/session";
33
+ import { ManduContext } from "../../filling/context";
34
+ import { ManduFilling } from "../../filling/filling";
35
+ import {
36
+ AuthenticationError,
37
+ requireUser,
38
+ type BaseUser,
39
+ } from "../../filling/auth";
40
+
41
+ // ========== Helpers ==========
42
+
43
+ const SECRET = "login-helper-test-secret-32bytes!";
44
+
45
+ function makeReq(url: string, init: RequestInit & { cookie?: string } = {}): Request {
46
+ const { cookie, headers: rawHeaders, ...rest } = init;
47
+ const headers = new Headers(rawHeaders as HeadersInit | undefined);
48
+ if (cookie) headers.set("cookie", cookie);
49
+ return new Request(url, { ...rest, headers });
50
+ }
51
+
52
+ function makeCtx(req: Request): ManduContext {
53
+ return new ManduContext(req);
54
+ }
55
+
56
+ function makeStorage(): SessionStorage {
57
+ return createCookieSessionStorage({
58
+ cookie: { secrets: [SECRET] },
59
+ });
60
+ }
61
+
62
+ /** Extract the first Set-Cookie line matching `name=`. */
63
+ function readSetCookieLine(res: Response, name: string): string | null {
64
+ const headers = res.headers.getSetCookie?.() ?? [];
65
+ const needle = `${name}=`;
66
+ for (const line of headers) {
67
+ if (line.startsWith(needle)) return line;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** Extract just the raw (encoded) cookie value from a Set-Cookie line. */
73
+ function readSetCookieRawValue(res: Response, name: string): string | null {
74
+ const line = readSetCookieLine(res, name);
75
+ if (!line) return null;
76
+ const [nv] = line.split(";");
77
+ const eq = nv.indexOf("=");
78
+ if (eq <= 0) return null;
79
+ return nv.slice(eq + 1).trim();
80
+ }
81
+
82
+ // ========== loginUser ==========
83
+
84
+ describe("loginUser — stores userId + loggedAt and commits", () => {
85
+ it("writes userId + loggedAt and emits Set-Cookie via saveSession", async () => {
86
+ const storage = makeStorage();
87
+ const mw = session({ storage });
88
+ const ctx = makeCtx(makeReq("http://localhost/"));
89
+ await mw(ctx);
90
+
91
+ await loginUser(ctx, "user-123");
92
+
93
+ // In-memory session state is visible immediately.
94
+ const s = ctx.get<Session>("session")!;
95
+ expect(s.get<string>("userId")).toBe("user-123");
96
+ expect(typeof s.get<number>("loginAt")).toBe("number");
97
+
98
+ // Set-Cookie lands on the next response built from this ctx.
99
+ const res = ctx.ok({ ok: true });
100
+ const line = readSetCookieLine(res, "__session");
101
+ expect(line).toBeTruthy();
102
+ expect(line!).toContain("HttpOnly");
103
+ });
104
+
105
+ it("honors a custom userIdKey", async () => {
106
+ const storage = makeStorage();
107
+ const mw = session({ storage });
108
+ const ctx = makeCtx(makeReq("http://localhost/"));
109
+ await mw(ctx);
110
+
111
+ await loginUser(ctx, "u-42", { userIdKey: "uid" });
112
+
113
+ const s = ctx.get<Session>("session")!;
114
+ expect(s.get<string>("uid")).toBe("u-42");
115
+ // Default key is NOT populated when custom key is supplied.
116
+ expect(s.get<string>("userId")).toBeUndefined();
117
+ });
118
+
119
+ it("writes extras atomically with userId + loggedAt", async () => {
120
+ const storage = makeStorage();
121
+ const mw = session({ storage });
122
+ const ctx = makeCtx(makeReq("http://localhost/"));
123
+ await mw(ctx);
124
+
125
+ await loginUser(ctx, "u-1", {
126
+ extras: {
127
+ role: "admin",
128
+ tenantId: "acme",
129
+ remember: true,
130
+ seat: 7,
131
+ },
132
+ });
133
+
134
+ const s = ctx.get<Session>("session")!;
135
+ expect(s.get<string>("userId")).toBe("u-1");
136
+ expect(s.get<string>("role")).toBe("admin");
137
+ expect(s.get<string>("tenantId")).toBe("acme");
138
+ expect(s.get<boolean>("remember")).toBe(true);
139
+ expect(s.get<number>("seat")).toBe(7);
140
+ });
141
+
142
+ it("throws AuthenticationError when session middleware is not installed", async () => {
143
+ // No `session()` middleware — ctx has no attached session.
144
+ const ctx = makeCtx(makeReq("http://localhost/"));
145
+
146
+ let thrown: unknown = null;
147
+ try {
148
+ await loginUser(ctx, "u-1");
149
+ } catch (err) {
150
+ thrown = err;
151
+ }
152
+ expect(thrown).toBeInstanceOf(AuthenticationError);
153
+ expect((thrown as AuthenticationError).statusCode).toBe(401);
154
+ expect(String(thrown)).toContain("Session middleware not installed");
155
+ });
156
+
157
+ it("rejects empty userId with AuthenticationError (never silently stores '')", async () => {
158
+ const storage = makeStorage();
159
+ const mw = session({ storage });
160
+ const ctx = makeCtx(makeReq("http://localhost/"));
161
+ await mw(ctx);
162
+
163
+ await expect(loginUser(ctx, "")).rejects.toBeInstanceOf(AuthenticationError);
164
+ });
165
+ });
166
+
167
+ // ========== logoutUser ==========
168
+
169
+ describe("logoutUser expiring cookie, idempotent", () => {
170
+ it("emits an expiring Set-Cookie (Max-Age=0)", async () => {
171
+ const storage = makeStorage();
172
+ const mw = session({ storage });
173
+ const ctx = makeCtx(makeReq("http://localhost/"));
174
+ await mw(ctx);
175
+ await loginUser(ctx, "u-1");
176
+
177
+ // Now log out on a fresh ctx with the just-issued cookie but for a unit
178
+ // test we can call logoutUser on the same ctx; destroySession will emit a
179
+ // new Set-Cookie line. There will be multiple Set-Cookie lines for
180
+ // __session because the first loginUser already queued one; we're checking
181
+ // that at least one Max-Age=0 line is present.
182
+ await logoutUser(ctx);
183
+ const res = ctx.ok({ ok: true });
184
+
185
+ const headers = res.headers.getSetCookie?.() ?? [];
186
+ const sessionLines = headers.filter((l) => l.startsWith("__session="));
187
+ expect(sessionLines.length).toBeGreaterThan(0);
188
+ const hasExpiring = sessionLines.some((l) => l.includes("Max-Age=0"));
189
+ expect(hasExpiring).toBe(true);
190
+ });
191
+
192
+ it("is idempotent two successive calls do not throw", async () => {
193
+ const storage = makeStorage();
194
+ const mw = session({ storage });
195
+ const ctx = makeCtx(makeReq("http://localhost/"));
196
+ await mw(ctx);
197
+
198
+ // No prior login session is empty. Two back-to-back logout calls must
199
+ // not throw. This exercises the "already logged out" code path.
200
+ await logoutUser(ctx);
201
+ await logoutUser(ctx);
202
+
203
+ const res = ctx.ok({ ok: true });
204
+ const line = readSetCookieLine(res, "__session");
205
+ expect(line).toBeTruthy();
206
+ expect(line!).toContain("Max-Age=0");
207
+ });
208
+
209
+ it("throws AuthenticationError when session middleware is not installed", async () => {
210
+ const ctx = makeCtx(makeReq("http://localhost/"));
211
+ await expect(logoutUser(ctx)).rejects.toBeInstanceOf(AuthenticationError);
212
+ });
213
+ });
214
+
215
+ // ========== currentUserId ==========
216
+
217
+ describe("currentUserId non-throwing reader", () => {
218
+ it("returns the userId after loginUser", async () => {
219
+ const storage = makeStorage();
220
+ const mw = session({ storage });
221
+ const ctx = makeCtx(makeReq("http://localhost/"));
222
+ await mw(ctx);
223
+
224
+ expect(currentUserId(ctx)).toBeNull();
225
+ await loginUser(ctx, "abc-999");
226
+ expect(currentUserId(ctx)).toBe("abc-999");
227
+ });
228
+
229
+ it("returns null WITHOUT throwing when session middleware is absent", () => {
230
+ const ctx = makeCtx(makeReq("http://localhost/"));
231
+ expect(() => currentUserId(ctx)).not.toThrow();
232
+ expect(currentUserId(ctx)).toBeNull();
233
+ });
234
+
235
+ it("returns null after logoutUser (in-memory wipe)", async () => {
236
+ const storage = makeStorage();
237
+ const mw = session({ storage });
238
+ const ctx = makeCtx(makeReq("http://localhost/"));
239
+ await mw(ctx);
240
+
241
+ await loginUser(ctx, "u-1");
242
+ expect(currentUserId(ctx)).toBe("u-1");
243
+
244
+ await logoutUser(ctx);
245
+ // destroySession wipes in-memory state → currentUserId sees no user.
246
+ expect(currentUserId(ctx)).toBeNull();
247
+ });
248
+
249
+ it("honors a custom userIdKey", async () => {
250
+ const storage = makeStorage();
251
+ const mw = session({ storage });
252
+ const ctx = makeCtx(makeReq("http://localhost/"));
253
+ await mw(ctx);
254
+
255
+ await loginUser(ctx, "u-custom", { userIdKey: "uid" });
256
+ expect(currentUserId(ctx)).toBeNull(); // default key empty
257
+ expect(currentUserId(ctx, { userIdKey: "uid" })).toBe("u-custom");
258
+ });
259
+ });
260
+
261
+ // ========== loggedAt ==========
262
+
263
+ describe("loggedAt numeric timestamp reader", () => {
264
+ it("returns a timestamp close to Date.now() after login (within 1s)", async () => {
265
+ const storage = makeStorage();
266
+ const mw = session({ storage });
267
+ const ctx = makeCtx(makeReq("http://localhost/"));
268
+ await mw(ctx);
269
+
270
+ const before = Date.now();
271
+ await loginUser(ctx, "u-1");
272
+ const after = Date.now();
273
+
274
+ const ts = loggedAt(ctx);
275
+ expect(ts).not.toBeNull();
276
+ expect(typeof ts).toBe("number");
277
+ expect(ts!).toBeGreaterThanOrEqual(before);
278
+ expect(ts!).toBeLessThanOrEqual(after);
279
+ });
280
+
281
+ it("returns null when session middleware is absent", () => {
282
+ const ctx = makeCtx(makeReq("http://localhost/"));
283
+ expect(loggedAt(ctx)).toBeNull();
284
+ });
285
+ });
286
+
287
+ // ========== End-to-end roundtrip through ManduFilling ==========
288
+
289
+ describe("roundtrip login cookie read → logout", () => {
290
+ it("completes a full login/read/logout flow across three requests", async () => {
291
+ const storage = makeStorage();
292
+
293
+ // Single filling pipeline handles all three paths via ?action=.
294
+ const filling = new ManduFilling()
295
+ .use(session({ storage }))
296
+ .get(async (ctx) => {
297
+ const action = new URL(ctx.request.url).searchParams.get("action");
298
+ if (action === "login") {
299
+ await loginUser(ctx, "roundtrip-user");
300
+ return ctx.ok({ stage: "login" });
301
+ }
302
+ if (action === "logout") {
303
+ await logoutUser(ctx);
304
+ return ctx.ok({ stage: "logout" });
305
+ }
306
+ // Default: read side.
307
+ return ctx.ok({ uid: currentUserId(ctx) });
308
+ });
309
+
310
+ // 1. Login capture the Set-Cookie the browser would keep.
311
+ const loginRes = await filling.handle(makeReq("http://localhost/?action=login"));
312
+ expect(loginRes.status).toBe(200);
313
+ const cookieLine = readSetCookieLine(loginRes, "__session");
314
+ expect(cookieLine).toBeTruthy();
315
+ const cookieValue = readSetCookieRawValue(loginRes, "__session");
316
+ expect(cookieValue).toBeTruthy();
317
+
318
+ // 2. Read — re-attach the issued cookie and verify currentUserId finds
319
+ // the id we stored.
320
+ const readRes = await filling.handle(
321
+ makeReq("http://localhost/", { cookie: `__session=${cookieValue}` }),
322
+ );
323
+ expect(readRes.status).toBe(200);
324
+ const readBody = (await readRes.json()) as { uid: string | null };
325
+ expect(readBody.uid).toBe("roundtrip-user");
326
+
327
+ // 3. Logout issues expiring cookie; subsequent read with *that* cookie
328
+ // returns null userId because the client would drop the cookie on
329
+ // Max-Age=0. We simulate this by not passing any cookie on the fourth
330
+ // request (emulating what the browser does after receiving the
331
+ // expiring Set-Cookie).
332
+ const logoutRes = await filling.handle(
333
+ makeReq("http://localhost/?action=logout", { cookie: `__session=${cookieValue}` }),
334
+ );
335
+ expect(logoutRes.status).toBe(200);
336
+ const logoutLine = readSetCookieLine(logoutRes, "__session");
337
+ expect(logoutLine).toBeTruthy();
338
+ expect(logoutLine!).toContain("Max-Age=0");
339
+
340
+ // 4. Post-logout read with NO cookie — currentUserId is null.
341
+ const postLogoutRes = await filling.handle(makeReq("http://localhost/"));
342
+ const postLogoutBody = (await postLogoutRes.json()) as { uid: string | null };
343
+ expect(postLogoutBody.uid).toBeNull();
344
+ });
345
+ });
346
+
347
+ // ========== Composition with requireUser from filling/auth ==========
348
+
349
+ describe("composition with requireUser the loadUser bridge pattern", () => {
350
+ it("a loadUser beforeHandle bridges session.userId ctx.set('user', ...) for requireUser", async () => {
351
+ // This is the common real-world pattern worth documenting as a test:
352
+ //
353
+ // - `loginUser` writes `userId` to the SESSION (persisted across
354
+ // requests via cookie).
355
+ // - `requireUser` reads the USER OBJECT from ctx.store at key "user"
356
+ // (a request-scoped store, wiped every request).
357
+ // - A tiny middleware placed AFTER `session()` reads the session
358
+ // userId, fetches the user record from your data layer, and calls
359
+ // `ctx.set("user", user)`. That's the bridge.
360
+
361
+ interface User extends BaseUser {
362
+ id: string;
363
+ name: string;
364
+ }
365
+
366
+ // Fake user store.
367
+ const users = new Map<string, User>([
368
+ ["u-1", { id: "u-1", name: "Alice" }],
369
+ ["u-2", { id: "u-2", name: "Bob" }],
370
+ ]);
371
+
372
+ const storage = makeStorage();
373
+
374
+ const filling = new ManduFilling()
375
+ .use(session({ storage }))
376
+ // ⭐️ The bridge: after session() installs the Session, hydrate the
377
+ // request-scoped `user` from the session's userId. Placed in
378
+ // beforeHandle so ALL subsequent handler code can rely on
379
+ // `requireUser(ctx)`.
380
+ .beforeHandle(async (ctx) => {
381
+ const uid = currentUserId(ctx);
382
+ if (uid) {
383
+ const user = users.get(uid);
384
+ if (user) ctx.set("user", user);
385
+ }
386
+ })
387
+ .get(async (ctx) => {
388
+ const action = new URL(ctx.request.url).searchParams.get("action");
389
+ if (action === "login") {
390
+ await loginUser(ctx, "u-1");
391
+ return ctx.ok({ stage: "login" });
392
+ }
393
+ // requireUser reads ctx.get("user") — the bridge populated it above
394
+ // IFF the session had a valid userId.
395
+ const user = requireUser<User>(ctx);
396
+ return ctx.ok({ id: user.id, name: user.name });
397
+ });
398
+
399
+ // 1. Login issues the cookie.
400
+ const loginRes = await filling.handle(makeReq("http://localhost/?action=login"));
401
+ expect(loginRes.status).toBe(200);
402
+ const cookieValue = readSetCookieRawValue(loginRes, "__session");
403
+ expect(cookieValue).toBeTruthy();
404
+
405
+ // 2. Second request re-attaches cookie → bridge hydrates → requireUser
406
+ // returns the full user record.
407
+ const res = await filling.handle(
408
+ makeReq("http://localhost/", { cookie: `__session=${cookieValue}` }),
409
+ );
410
+ expect(res.status).toBe(200);
411
+ const body = (await res.json()) as { id: string; name: string };
412
+ expect(body.id).toBe("u-1");
413
+ expect(body.name).toBe("Alice");
414
+
415
+ // 3. Third request with NO cookie no session userId → bridge does
416
+ // nothing requireUser throws filling maps to 401.
417
+ const unauthRes = await filling.handle(makeReq("http://localhost/"));
418
+ expect(unauthRes.status).toBe(401);
419
+ });
420
+ });