@mandujs/core 0.53.2 → 0.54.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.
- package/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -380
- package/src/diagnose/checks.ts +832 -720
- package/src/diagnose/index.ts +17 -16
- package/src/diagnose/run.ts +93 -91
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
package/src/auth/reset.ts
CHANGED
|
@@ -1,243 +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
|
-
}
|
|
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
|
+
}
|