@mandujs/core 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
package/package.json CHANGED
@@ -1,69 +1,101 @@
1
- {
2
- "name": "@mandujs/core",
3
- "version": "0.21.0",
4
- "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts",
10
- "./client": "./src/client/index.ts",
11
- "./middleware": "./src/middleware/index.ts",
12
- "./testing": "./src/testing/index.ts",
13
- "./plugins": "./src/plugins/index.ts",
14
- "./error": "./src/error/index.ts",
15
- "./observability": "./src/observability/index.ts",
16
- "./bundler/prerender": "./src/bundler/prerender.ts",
17
- "./*": "./src/*"
18
- },
19
- "files": [
20
- "src/**/*"
21
- ],
22
- "scripts": {
23
- "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
24
- "test:hydration": "bun test tests/hydration",
25
- "test:streaming": "bun test tests/streaming-ssr",
26
- "test:watch": "bun test --watch"
27
- },
28
- "devDependencies": {
29
- "@happy-dom/global-registrator": "^15.0.0"
30
- },
31
- "keywords": [
32
- "mandu",
33
- "framework",
34
- "agent",
35
- "ai",
36
- "code-generation"
37
- ],
38
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/konamgil/mandu.git",
41
- "directory": "packages/core"
42
- },
43
- "author": "konamgil",
44
- "license": "MPL-2.0",
45
- "publishConfig": {
46
- "access": "public"
47
- },
48
- "engines": {
49
- "bun": ">=1.0.0"
50
- },
51
- "peerDependencies": {
52
- "react": "^19.0.0",
53
- "react-dom": "^19.0.0",
54
- "@tailwindcss/cli": ">=4.0.0"
55
- },
56
- "peerDependenciesMeta": {
57
- "@tailwindcss/cli": {
58
- "optional": true
59
- }
60
- },
61
- "dependencies": {
62
- "chokidar": "^5.0.0",
63
- "fast-glob": "^3.3.2",
64
- "glob": "^13.0.0",
65
- "minimatch": "^10.1.1",
66
- "ollama": "^0.6.3",
67
- "zod": "^3.23.8"
68
- }
69
- }
1
+ {
2
+ "name": "@mandujs/core",
3
+ "version": "0.22.1",
4
+ "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
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",
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",
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",
25
+ "./testing": "./src/testing/index.ts",
26
+ "./plugins": "./src/plugins/index.ts",
27
+ "./error": "./src/error/index.ts",
28
+ "./id": "./src/id/index.ts",
29
+ "./observability": "./src/observability/index.ts",
30
+ "./perf": "./src/perf/index.ts",
31
+ "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
32
+ "./scheduler": "./src/scheduler/index.ts",
33
+ "./storage/s3": "./src/storage/s3/index.ts",
34
+ "./bundler/prerender": "./src/bundler/prerender.ts",
35
+ "./bundler/safe-build": "./src/bundler/safe-build.ts",
36
+ "./bundler/hmr-types": "./src/bundler/hmr-types.ts",
37
+ "./bundler/scenario-matrix": "./src/bundler/scenario-matrix.ts",
38
+ "./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
39
+ "./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
40
+ "./runtime/fast-refresh-types": "./src/runtime/fast-refresh-types.ts",
41
+ "./*": "./src/*"
42
+ },
43
+ "files": [
44
+ "src/**/*"
45
+ ],
46
+ "scripts": {
47
+ "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
48
+ "test:hydration": "bun test tests/hydration",
49
+ "test:streaming": "bun test tests/streaming-ssr",
50
+ "test:watch": "bun test --watch"
51
+ },
52
+ "devDependencies": {
53
+ "@happy-dom/global-registrator": "^15.0.0"
54
+ },
55
+ "keywords": [
56
+ "mandu",
57
+ "framework",
58
+ "agent",
59
+ "ai",
60
+ "code-generation"
61
+ ],
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/konamgil/mandu.git",
65
+ "directory": "packages/core"
66
+ },
67
+ "author": "konamgil",
68
+ "license": "MPL-2.0",
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "engines": {
73
+ "bun": ">=1.3.12"
74
+ },
75
+ "peerDependencies": {
76
+ "react": "^19.0.0",
77
+ "react-dom": "^19.0.0",
78
+ "react-refresh": ">=0.18.0",
79
+ "@tailwindcss/cli": ">=4.0.0",
80
+ "webview-bun": "^2.4.0"
81
+ },
82
+ "peerDependenciesMeta": {
83
+ "@tailwindcss/cli": {
84
+ "optional": true
85
+ },
86
+ "react-refresh": {
87
+ "optional": true
88
+ },
89
+ "webview-bun": {
90
+ "optional": true
91
+ }
92
+ },
93
+ "dependencies": {
94
+ "chokidar": "^5.0.0",
95
+ "fast-glob": "^3.3.2",
96
+ "glob": "^13.0.0",
97
+ "minimatch": "^10.1.1",
98
+ "ollama": "^0.6.3",
99
+ "zod": "^3.23.8"
100
+ }
101
+ }
@@ -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
+ });