@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
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @mandujs/core/auth/reset — password-reset flow (Phase 5.3).
3
+ *
4
+ * Flow:
5
+ * 1. Caller invokes `send(userId, email)` after verifying the email belongs
6
+ * to the account (typically via a "forgot password" form that looks up
7
+ * the user by email).
8
+ * 2. We mint a single-use token, render a link, and hand the rendered
9
+ * message to the caller's {@link EmailSender}.
10
+ * 3. The user clicks the link, enters a new password, and the landing
11
+ * route calls `consume(token, newPassword)`.
12
+ * 4. On success, `onReset({ userId, newHash })` fires — the caller stores
13
+ * the new hash on their user record.
14
+ *
15
+ * ## Security shape
16
+ *
17
+ * - **No auto-login.** The user must re-authenticate with the new password.
18
+ * This is intentional: a reset token is a "please let me update my
19
+ * password" capability, not a session. If an attacker intercepts the
20
+ * link, we want them to still need to enter the new password they just
21
+ * set (which the legitimate user may immediately invalidate via a new
22
+ * reset). Auto-login would make the token a session bearer.
23
+ * - **Plaintext never escapes the consume() boundary.** `onReset` receives
24
+ * only the argon2id hash (computed here via `hashPassword`). The
25
+ * plaintext is held briefly in consume's local scope; we do not log it
26
+ * and we don't forward it to any callback.
27
+ * - **Token binds to userId, NOT to email.** Meta is empty on reset —
28
+ * unlike verification, the attacker already knows the email (they asked
29
+ * for the reset). What matters is that they control the inbox.
30
+ * - **No rate limiting.** Caller must gate `send()` — a classic pattern is
31
+ * "1 reset request per minute per email AND per IP". Phase 6 middleware.
32
+ *
33
+ * ## Non-goals (handled by caller)
34
+ *
35
+ * - Invalidating existing sessions after a successful reset. If you want
36
+ * that, call `destroySession` on all sessions for `userId` from within
37
+ * your `onReset` callback (your session store's responsibility).
38
+ * - Sending a "your password was changed" notification email. Recommended
39
+ * — do it in `onReset`.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { createPasswordReset } from "@mandujs/core/auth/reset";
44
+ *
45
+ * const reset = createPasswordReset({
46
+ * store,
47
+ * sender: mail,
48
+ * fromAddress: "noreply@example.com",
49
+ * resetUrlTemplate: "https://app.example.com/reset?token={token}",
50
+ * renderEmail: ({ url }) => ({
51
+ * subject: "Reset your password",
52
+ * html: `<p><a href="${url}">Reset password</a></p>`,
53
+ * }),
54
+ * onReset: async ({ userId, newHash }) => {
55
+ * await db.users.update(userId, { passwordHash: newHash });
56
+ * },
57
+ * });
58
+ *
59
+ * await reset.send("u-1", "alice@example.com");
60
+ * const ok = await reset.consume(tokenFromQuery, newPasswordFromForm);
61
+ * if (!ok) return ctx.badRequest("invalid or expired token");
62
+ * ```
63
+ *
64
+ * @module auth/reset
65
+ */
66
+
67
+ import type { EmailSender } from "../email/index.js";
68
+ import { hashPassword, type PasswordOptions } from "./password.js";
69
+ import type { AuthTokenStore } from "./tokens.js";
70
+
71
+ // ─── Public types ───────────────────────────────────────────────────────────
72
+
73
+ /** Construction options for {@link createPasswordReset}. */
74
+ export interface ResetFlowOptions {
75
+ /** Token store from {@link createAuthTokenStore}. */
76
+ store: AuthTokenStore;
77
+ /** Email transport. */
78
+ sender: EmailSender;
79
+ /**
80
+ * `From:` address stamped on every outbound reset message. Required — see
81
+ * the same rationale in `VerificationFlowOptions.fromAddress`.
82
+ */
83
+ fromAddress: string;
84
+ /**
85
+ * URL template for the reset link. Must include the literal `{token}`
86
+ * placeholder.
87
+ */
88
+ resetUrlTemplate: string;
89
+ /**
90
+ * Render the email body. Returned object is forwarded to
91
+ * {@link EmailSender.send} — `subject` required, at least one of
92
+ * `html` / `text`.
93
+ */
94
+ renderEmail: (args: {
95
+ url: string;
96
+ userId: string;
97
+ email: string;
98
+ }) => { subject: string; html?: string; text?: string };
99
+ /**
100
+ * Password hashing options forwarded to `hashPassword`. Defaults to
101
+ * argon2id with Bun's default cost. Override to set stricter cost
102
+ * parameters for production, or for legacy bcrypt interop.
103
+ */
104
+ passwordOptions?: PasswordOptions;
105
+ /**
106
+ * Called after `consume()` marks a token used AND `hashPassword` has
107
+ * returned the new hash. The caller persists `newHash` on the user
108
+ * record. Safe to kick off session invalidation from here.
109
+ */
110
+ onReset: (args: { userId: string; newHash: string }) => Promise<void>;
111
+ }
112
+
113
+ /** Public surface returned by {@link createPasswordReset}. */
114
+ export interface ResetFlow {
115
+ /** Mint a reset token and send the email. Rate-limit the caller. */
116
+ send(userId: string, email: string): Promise<void>;
117
+ /**
118
+ * Consume a reset token and set a new password. Returns `{ userId }` on
119
+ * success, `null` on any failure mode.
120
+ *
121
+ * Throws:
122
+ * - `TypeError` when `newPassword` is empty.
123
+ * - Errors from `hashPassword` (bcrypt 72-byte limit, missing Bun, etc.)
124
+ * — these run AFTER the token has been consumed. Your `onReset` is
125
+ * NOT invoked in that case; the caller should treat a thrown
126
+ * `hashPassword` as "token spent, ask for another reset".
127
+ */
128
+ consume(token: string, newPassword: string): Promise<{ userId: string } | null>;
129
+ }
130
+
131
+ // ─── Constants ──────────────────────────────────────────────────────────────
132
+
133
+ const URL_PLACEHOLDER = "{token}";
134
+ const PURPOSE = "reset-password" as const;
135
+
136
+ // ─── Factory ────────────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Wire up a password-reset flow.
140
+ *
141
+ * @throws {TypeError} Synchronously when `resetUrlTemplate` is missing the
142
+ * `{token}` placeholder, or when `fromAddress` is empty.
143
+ */
144
+ export function createPasswordReset(options: ResetFlowOptions): ResetFlow {
145
+ const {
146
+ store,
147
+ sender,
148
+ fromAddress,
149
+ resetUrlTemplate,
150
+ renderEmail,
151
+ passwordOptions,
152
+ onReset,
153
+ } = options;
154
+
155
+ if (typeof fromAddress !== "string" || fromAddress.length === 0) {
156
+ throw new TypeError(
157
+ "[@mandujs/core/auth/reset] createPasswordReset: 'fromAddress' is required and must be a non-empty string.",
158
+ );
159
+ }
160
+ if (typeof resetUrlTemplate !== "string" || !resetUrlTemplate.includes(URL_PLACEHOLDER)) {
161
+ throw new TypeError(
162
+ `[@mandujs/core/auth/reset] createPasswordReset: 'resetUrlTemplate' must include the literal '${URL_PLACEHOLDER}' placeholder.`,
163
+ );
164
+ }
165
+
166
+ async function send(userId: string, email: string): Promise<void> {
167
+ if (typeof userId !== "string" || userId.length === 0) {
168
+ throw new TypeError(
169
+ "[@mandujs/core/auth/reset] send: userId must be a non-empty string.",
170
+ );
171
+ }
172
+ if (typeof email !== "string" || email.length === 0) {
173
+ throw new TypeError(
174
+ "[@mandujs/core/auth/reset] send: email must be a non-empty string.",
175
+ );
176
+ }
177
+
178
+ // No meta — the email is not re-surfaced by consume (the caller
179
+ // already knows the userId). Keeping meta empty keeps rows smaller and
180
+ // reduces exposure if the DB leaks.
181
+ const { token } = await store.mint(PURPOSE, userId);
182
+
183
+ const url = resetUrlTemplate.replace(URL_PLACEHOLDER, encodeURIComponent(token));
184
+
185
+ const rendered = renderEmail({ url, userId, email });
186
+ if (!rendered || typeof rendered !== "object") {
187
+ throw new TypeError(
188
+ "[@mandujs/core/auth/reset] renderEmail: must return { subject, html?, text? }.",
189
+ );
190
+ }
191
+
192
+ await sender.send({
193
+ from: fromAddress,
194
+ to: email,
195
+ subject: rendered.subject,
196
+ html: rendered.html,
197
+ text: rendered.text,
198
+ });
199
+ }
200
+
201
+ async function consume(
202
+ token: string,
203
+ newPassword: string,
204
+ ): Promise<{ userId: string } | null> {
205
+ // Validate the password BEFORE consuming the token — a clear error
206
+ // here means the user can retry with the same token. (If we consumed
207
+ // first and then blew up on validation, they'd need a new reset email
208
+ // for a fixable typo.)
209
+ if (typeof newPassword !== "string" || newPassword.length === 0) {
210
+ throw new TypeError(
211
+ "[@mandujs/core/auth/reset] consume: newPassword must be a non-empty string.",
212
+ );
213
+ }
214
+
215
+ const decoded = safeDecodeURIComponent(token);
216
+ if (decoded === null) return null;
217
+
218
+ const record = await store.consume(PURPOSE, decoded);
219
+ if (!record) return null;
220
+
221
+ // Token is spent. From here on, any error is propagated to the
222
+ // caller — but the token cannot be retried. `hashPassword` throws on
223
+ // the bcrypt 72-byte limit; we deliberately DO NOT catch that (the
224
+ // test suite asserts the throw propagates).
225
+ const newHash = await hashPassword(newPassword, passwordOptions);
226
+
227
+ await onReset({ userId: record.userId, newHash });
228
+ return { userId: record.userId };
229
+ }
230
+
231
+ return { send, consume };
232
+ }
233
+
234
+ // ─── Helpers ────────────────────────────────────────────────────────────────
235
+
236
+ function safeDecodeURIComponent(value: string): string | null {
237
+ if (typeof value !== "string" || value.length === 0) return null;
238
+ try {
239
+ return decodeURIComponent(value);
240
+ } catch {
241
+ return null;
242
+ }
243
+ }