@mandujs/core 0.54.31 → 0.55.0-beta.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.
- package/README.md +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- 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 +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- 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 +14 -2
- 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/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- 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 +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- 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/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- 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 +16 -15
- 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/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- 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__/props-serialization.test.ts +37 -37
- 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 +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- 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-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- 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/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- 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/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- 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 +82 -82
- 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__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- 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/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- 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/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 +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- 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/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/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- 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/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 +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- 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/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- 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/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- 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/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- 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/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- 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/perf/user-marks.ts +1 -1
- 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 +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- 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/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- 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__/devtools-adapter.test.ts +68 -68
- 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__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- 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/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- 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/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +39 -20
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- 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/spec/schema.ts +35 -35
- 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/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- 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/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
package/src/auth/verification.ts
CHANGED
|
@@ -1,253 +1,253 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/auth/verification — email verification flow (Phase 5.3).
|
|
3
|
-
*
|
|
4
|
-
* Flow:
|
|
5
|
-
* 1. Caller collects an email at signup and invokes `send(userId, email)`.
|
|
6
|
-
* 2. We mint a single-use token, render a link, and hand the rendered
|
|
7
|
-
* message to the caller's {@link EmailSender}.
|
|
8
|
-
* 3. The user clicks the link. The landing route invokes
|
|
9
|
-
* `consume(tokenFromUrl)`.
|
|
10
|
-
* 4. On success, `onVerified({ userId, email })` fires — the caller
|
|
11
|
-
* persists the "verified" flag on their user record.
|
|
12
|
-
*
|
|
13
|
-
* ## What this module does NOT do
|
|
14
|
-
*
|
|
15
|
-
* - **No user-record mutation.** We don't know what your `users` table
|
|
16
|
-
* looks like. The `onVerified` callback is your hook.
|
|
17
|
-
* - **No auto-login.** Verification is an identity claim, not a session.
|
|
18
|
-
* If you want "verify and log in", do both in your landing route.
|
|
19
|
-
* - **No rate limiting.** Caller must gate `send()` (suggested: 1/min per
|
|
20
|
-
* userId) to prevent outbound-email spam from a malicious attacker who
|
|
21
|
-
* knows someone's account id. Phase 6 rate-limit middleware can wrap
|
|
22
|
-
* this.
|
|
23
|
-
* - **No storage side-effects on failed consume.** A bogus / expired token
|
|
24
|
-
* quietly returns `null` — do NOT log which check failed (would leak
|
|
25
|
-
* signal to brute-forcers).
|
|
26
|
-
*
|
|
27
|
-
* ## Idempotency caveat
|
|
28
|
-
*
|
|
29
|
-
* `consume()` marks the token used before invoking `onVerified`. If your
|
|
30
|
-
* callback throws, the token is already consumed — the user would need a
|
|
31
|
-
* fresh verification email. This is the safer trade-off: we'd rather
|
|
32
|
-
* force a resend than leave a window where a single token can be
|
|
33
|
-
* re-consumed and trigger `onVerified` twice. Make `onVerified` idempotent
|
|
34
|
-
* so double-invocation (from a race elsewhere in your stack) is harmless.
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* ```ts
|
|
38
|
-
* import { createEmailVerification } from "@mandujs/core/auth/verification";
|
|
39
|
-
* import { createAuthTokenStore } from "@mandujs/core/auth/tokens"; // internal
|
|
40
|
-
*
|
|
41
|
-
* const store = createAuthTokenStore({ secret: process.env.TOKEN_SECRET! });
|
|
42
|
-
* const verify = createEmailVerification({
|
|
43
|
-
* store,
|
|
44
|
-
* sender: mail,
|
|
45
|
-
* fromAddress: "noreply@example.com",
|
|
46
|
-
* verifyUrlTemplate: "https://app.example.com/verify?token={token}",
|
|
47
|
-
* renderEmail: ({ url }) => ({
|
|
48
|
-
* subject: "Verify your email",
|
|
49
|
-
* html: `<p>Click <a href="${url}">here</a>.</p>`,
|
|
50
|
-
* }),
|
|
51
|
-
* onVerified: async ({ userId, email }) => {
|
|
52
|
-
* await db.users.update(userId, { emailVerifiedAt: new Date(), email });
|
|
53
|
-
* },
|
|
54
|
-
* });
|
|
55
|
-
*
|
|
56
|
-
* await verify.send("u-1", "alice@example.com");
|
|
57
|
-
* // later, in the verify route:
|
|
58
|
-
* const ok = await verify.consume(tokenFromQuery);
|
|
59
|
-
* if (!ok) return ctx.badRequest("invalid or expired token");
|
|
60
|
-
* ```
|
|
61
|
-
*
|
|
62
|
-
* @module auth/verification
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
import type { EmailSender } from "../email/index.js";
|
|
66
|
-
import type { AuthTokenStore, TokenRecord } from "./tokens.js";
|
|
67
|
-
|
|
68
|
-
// ─── Public types ───────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
/** Construction options for {@link createEmailVerification}. */
|
|
71
|
-
export interface VerificationFlowOptions {
|
|
72
|
-
/** Token store from {@link createAuthTokenStore}. */
|
|
73
|
-
store: AuthTokenStore;
|
|
74
|
-
/** Email transport. See `@mandujs/core/email` for provider options. */
|
|
75
|
-
sender: EmailSender;
|
|
76
|
-
/**
|
|
77
|
-
* `From:` address stamped on every outbound verification message. Accepts
|
|
78
|
-
* bare (`noreply@example.com`) or display-name (`"App <noreply@…>"`) form —
|
|
79
|
-
* passed through to the provider, which does final validation.
|
|
80
|
-
*
|
|
81
|
-
* Required (no default) because a wrong `From:` on a transactional email
|
|
82
|
-
* gets the entire domain flagged by the provider. The caller picks.
|
|
83
|
-
*/
|
|
84
|
-
fromAddress: string;
|
|
85
|
-
/**
|
|
86
|
-
* URL template for the verification link. Must contain the literal
|
|
87
|
-
* `{token}` placeholder — we substitute it with `encodeURIComponent(token)`
|
|
88
|
-
* at send time.
|
|
89
|
-
*
|
|
90
|
-
* @example `"https://app.example.com/verify?token={token}"`
|
|
91
|
-
*/
|
|
92
|
-
verifyUrlTemplate: string;
|
|
93
|
-
/**
|
|
94
|
-
* Render the email body. Returned object is forwarded to
|
|
95
|
-
* {@link EmailSender.send} — `subject` is required; you must provide at
|
|
96
|
-
* least one of `html` / `text`.
|
|
97
|
-
*/
|
|
98
|
-
renderEmail: (args: {
|
|
99
|
-
url: string;
|
|
100
|
-
userId: string;
|
|
101
|
-
email: string;
|
|
102
|
-
}) => { subject: string; html?: string; text?: string };
|
|
103
|
-
/**
|
|
104
|
-
* Called after `consume()` marks a token used. Receives the verified
|
|
105
|
-
* `{ userId, email }`. If this throws, the error propagates to the
|
|
106
|
-
* `consume` caller — but the token is already consumed. Make this
|
|
107
|
-
* idempotent.
|
|
108
|
-
*/
|
|
109
|
-
onVerified: (args: { userId: string; email: string }) => Promise<void>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Public surface returned by {@link createEmailVerification}. */
|
|
113
|
-
export interface VerificationFlow {
|
|
114
|
-
/**
|
|
115
|
-
* Mint a verification token, render the email with the link embedded,
|
|
116
|
-
* and hand it off to the sender. Callers should rate-limit this per
|
|
117
|
-
* userId — see Phase 6 rate-limit middleware.
|
|
118
|
-
*/
|
|
119
|
-
send(userId: string, email: string): Promise<void>;
|
|
120
|
-
/**
|
|
121
|
-
* Consume a token. Returns `{ userId, email }` on success, `null` on any
|
|
122
|
-
* failure mode (unknown / expired / already-used / tampered / wrong
|
|
123
|
-
* purpose). Never throws on bad input.
|
|
124
|
-
*
|
|
125
|
-
* NOTE: if `onVerified` throws, the token is already consumed and the
|
|
126
|
-
* error propagates. See the module-level "Idempotency caveat".
|
|
127
|
-
*/
|
|
128
|
-
consume(token: string): Promise<{ userId: string; email: string } | null>;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
const URL_PLACEHOLDER = "{token}";
|
|
134
|
-
const PURPOSE = "verify-email" as const;
|
|
135
|
-
|
|
136
|
-
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Wire up an email-verification flow.
|
|
140
|
-
*
|
|
141
|
-
* @throws {TypeError} Synchronously when `verifyUrlTemplate` is missing the
|
|
142
|
-
* `{token}` placeholder, or when `fromAddress` is empty.
|
|
143
|
-
*/
|
|
144
|
-
export function createEmailVerification(
|
|
145
|
-
options: VerificationFlowOptions,
|
|
146
|
-
): VerificationFlow {
|
|
147
|
-
const { store, sender, fromAddress, verifyUrlTemplate, renderEmail, onVerified } =
|
|
148
|
-
options;
|
|
149
|
-
|
|
150
|
-
if (typeof fromAddress !== "string" || fromAddress.length === 0) {
|
|
151
|
-
throw new TypeError(
|
|
152
|
-
"[@mandujs/core/auth/verification] createEmailVerification: 'fromAddress' is required and must be a non-empty string.",
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
if (typeof verifyUrlTemplate !== "string" || !verifyUrlTemplate.includes(URL_PLACEHOLDER)) {
|
|
156
|
-
throw new TypeError(
|
|
157
|
-
`[@mandujs/core/auth/verification] createEmailVerification: 'verifyUrlTemplate' must include the literal '${URL_PLACEHOLDER}' placeholder.`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async function send(userId: string, email: string): Promise<void> {
|
|
162
|
-
if (typeof userId !== "string" || userId.length === 0) {
|
|
163
|
-
throw new TypeError(
|
|
164
|
-
"[@mandujs/core/auth/verification] send: userId must be a non-empty string.",
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
if (typeof email !== "string" || email.length === 0) {
|
|
168
|
-
throw new TypeError(
|
|
169
|
-
"[@mandujs/core/auth/verification] send: email must be a non-empty string.",
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Persist the email-under-verification in `meta` so `consume()` can
|
|
174
|
-
// surface it back to `onVerified`. The token itself binds to the userId
|
|
175
|
-
// at the store level; `meta.email` is what the user is CLAIMING to
|
|
176
|
-
// control at send time. They prove control by receiving the link.
|
|
177
|
-
const { token } = await store.mint(PURPOSE, userId, { email });
|
|
178
|
-
|
|
179
|
-
// Base64url-safe nonces mean `encodeURIComponent` is effectively a
|
|
180
|
-
// no-op — we still wrap so a future nonce-alphabet change can't
|
|
181
|
-
// silently produce broken URLs.
|
|
182
|
-
const url = verifyUrlTemplate.replace(URL_PLACEHOLDER, encodeURIComponent(token));
|
|
183
|
-
|
|
184
|
-
const rendered = renderEmail({ url, userId, email });
|
|
185
|
-
if (!rendered || typeof rendered !== "object") {
|
|
186
|
-
throw new TypeError(
|
|
187
|
-
"[@mandujs/core/auth/verification] renderEmail: must return { subject, html?, text? }.",
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await sender.send({
|
|
192
|
-
from: fromAddress,
|
|
193
|
-
to: email,
|
|
194
|
-
subject: rendered.subject,
|
|
195
|
-
html: rendered.html,
|
|
196
|
-
text: rendered.text,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function consume(
|
|
201
|
-
token: string,
|
|
202
|
-
): Promise<{ userId: string; email: string } | null> {
|
|
203
|
-
// `parseToken` inside the store handles `null`/malformed input by
|
|
204
|
-
// returning null — but our wire layer may have URL-encoded the token,
|
|
205
|
-
// so reverse the encoding we applied in `send()` first. A malformed
|
|
206
|
-
// %-sequence short-circuits to null (never throw on user input).
|
|
207
|
-
const decoded = safeDecodeURIComponent(token);
|
|
208
|
-
if (decoded === null) return null;
|
|
209
|
-
|
|
210
|
-
const record = await store.consume(PURPOSE, decoded);
|
|
211
|
-
if (!record) return null;
|
|
212
|
-
|
|
213
|
-
const email = extractEmail(record);
|
|
214
|
-
if (!email) {
|
|
215
|
-
// Token was valid but the email is missing from meta — indicates a
|
|
216
|
-
// store inconsistency (hand-edited row?). Treat as bogus rather than
|
|
217
|
-
// invoking `onVerified` without an email.
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
await onVerified({ userId: record.userId, email });
|
|
222
|
-
return { userId: record.userId, email };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return { send, consume };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* `decodeURIComponent` throws on malformed `%XX`. User-supplied query
|
|
232
|
-
* strings can carry bad encodings, so we wrap and normalise to null.
|
|
233
|
-
*/
|
|
234
|
-
function safeDecodeURIComponent(value: string): string | null {
|
|
235
|
-
if (typeof value !== "string" || value.length === 0) return null;
|
|
236
|
-
try {
|
|
237
|
-
return decodeURIComponent(value);
|
|
238
|
-
} catch {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Pull `email` from `record.meta`. Returns `null` when meta is missing or
|
|
245
|
-
* the key is not a non-empty string. Defensive against a hand-edited DB
|
|
246
|
-
* (or a future migration) where meta ends up with unexpected shape.
|
|
247
|
-
*/
|
|
248
|
-
function extractEmail(record: TokenRecord): string | null {
|
|
249
|
-
const meta = record.meta;
|
|
250
|
-
if (!meta || typeof meta !== "object") return null;
|
|
251
|
-
const raw = meta.email;
|
|
252
|
-
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
253
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/verification — email verification flow (Phase 5.3).
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Caller collects an email at signup and invokes `send(userId, email)`.
|
|
6
|
+
* 2. We mint a single-use token, render a link, and hand the rendered
|
|
7
|
+
* message to the caller's {@link EmailSender}.
|
|
8
|
+
* 3. The user clicks the link. The landing route invokes
|
|
9
|
+
* `consume(tokenFromUrl)`.
|
|
10
|
+
* 4. On success, `onVerified({ userId, email })` fires — the caller
|
|
11
|
+
* persists the "verified" flag on their user record.
|
|
12
|
+
*
|
|
13
|
+
* ## What this module does NOT do
|
|
14
|
+
*
|
|
15
|
+
* - **No user-record mutation.** We don't know what your `users` table
|
|
16
|
+
* looks like. The `onVerified` callback is your hook.
|
|
17
|
+
* - **No auto-login.** Verification is an identity claim, not a session.
|
|
18
|
+
* If you want "verify and log in", do both in your landing route.
|
|
19
|
+
* - **No rate limiting.** Caller must gate `send()` (suggested: 1/min per
|
|
20
|
+
* userId) to prevent outbound-email spam from a malicious attacker who
|
|
21
|
+
* knows someone's account id. Phase 6 rate-limit middleware can wrap
|
|
22
|
+
* this.
|
|
23
|
+
* - **No storage side-effects on failed consume.** A bogus / expired token
|
|
24
|
+
* quietly returns `null` — do NOT log which check failed (would leak
|
|
25
|
+
* signal to brute-forcers).
|
|
26
|
+
*
|
|
27
|
+
* ## Idempotency caveat
|
|
28
|
+
*
|
|
29
|
+
* `consume()` marks the token used before invoking `onVerified`. If your
|
|
30
|
+
* callback throws, the token is already consumed — the user would need a
|
|
31
|
+
* fresh verification email. This is the safer trade-off: we'd rather
|
|
32
|
+
* force a resend than leave a window where a single token can be
|
|
33
|
+
* re-consumed and trigger `onVerified` twice. Make `onVerified` idempotent
|
|
34
|
+
* so double-invocation (from a race elsewhere in your stack) is harmless.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { createEmailVerification } from "@mandujs/core/compat/auth/verification";
|
|
39
|
+
* import { createAuthTokenStore } from "@mandujs/core/compat/auth/tokens"; // internal
|
|
40
|
+
*
|
|
41
|
+
* const store = createAuthTokenStore({ secret: process.env.TOKEN_SECRET! });
|
|
42
|
+
* const verify = createEmailVerification({
|
|
43
|
+
* store,
|
|
44
|
+
* sender: mail,
|
|
45
|
+
* fromAddress: "noreply@example.com",
|
|
46
|
+
* verifyUrlTemplate: "https://app.example.com/verify?token={token}",
|
|
47
|
+
* renderEmail: ({ url }) => ({
|
|
48
|
+
* subject: "Verify your email",
|
|
49
|
+
* html: `<p>Click <a href="${url}">here</a>.</p>`,
|
|
50
|
+
* }),
|
|
51
|
+
* onVerified: async ({ userId, email }) => {
|
|
52
|
+
* await db.users.update(userId, { emailVerifiedAt: new Date(), email });
|
|
53
|
+
* },
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* await verify.send("u-1", "alice@example.com");
|
|
57
|
+
* // later, in the verify route:
|
|
58
|
+
* const ok = await verify.consume(tokenFromQuery);
|
|
59
|
+
* if (!ok) return ctx.badRequest("invalid or expired token");
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* @module auth/verification
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { EmailSender } from "../email/index.js";
|
|
66
|
+
import type { AuthTokenStore, TokenRecord } from "./tokens.js";
|
|
67
|
+
|
|
68
|
+
// ─── Public types ───────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** Construction options for {@link createEmailVerification}. */
|
|
71
|
+
export interface VerificationFlowOptions {
|
|
72
|
+
/** Token store from {@link createAuthTokenStore}. */
|
|
73
|
+
store: AuthTokenStore;
|
|
74
|
+
/** Email transport. See `@mandujs/core/email` for provider options. */
|
|
75
|
+
sender: EmailSender;
|
|
76
|
+
/**
|
|
77
|
+
* `From:` address stamped on every outbound verification message. Accepts
|
|
78
|
+
* bare (`noreply@example.com`) or display-name (`"App <noreply@…>"`) form —
|
|
79
|
+
* passed through to the provider, which does final validation.
|
|
80
|
+
*
|
|
81
|
+
* Required (no default) because a wrong `From:` on a transactional email
|
|
82
|
+
* gets the entire domain flagged by the provider. The caller picks.
|
|
83
|
+
*/
|
|
84
|
+
fromAddress: string;
|
|
85
|
+
/**
|
|
86
|
+
* URL template for the verification link. Must contain the literal
|
|
87
|
+
* `{token}` placeholder — we substitute it with `encodeURIComponent(token)`
|
|
88
|
+
* at send time.
|
|
89
|
+
*
|
|
90
|
+
* @example `"https://app.example.com/verify?token={token}"`
|
|
91
|
+
*/
|
|
92
|
+
verifyUrlTemplate: string;
|
|
93
|
+
/**
|
|
94
|
+
* Render the email body. Returned object is forwarded to
|
|
95
|
+
* {@link EmailSender.send} — `subject` is required; you must provide at
|
|
96
|
+
* least one of `html` / `text`.
|
|
97
|
+
*/
|
|
98
|
+
renderEmail: (args: {
|
|
99
|
+
url: string;
|
|
100
|
+
userId: string;
|
|
101
|
+
email: string;
|
|
102
|
+
}) => { subject: string; html?: string; text?: string };
|
|
103
|
+
/**
|
|
104
|
+
* Called after `consume()` marks a token used. Receives the verified
|
|
105
|
+
* `{ userId, email }`. If this throws, the error propagates to the
|
|
106
|
+
* `consume` caller — but the token is already consumed. Make this
|
|
107
|
+
* idempotent.
|
|
108
|
+
*/
|
|
109
|
+
onVerified: (args: { userId: string; email: string }) => Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Public surface returned by {@link createEmailVerification}. */
|
|
113
|
+
export interface VerificationFlow {
|
|
114
|
+
/**
|
|
115
|
+
* Mint a verification token, render the email with the link embedded,
|
|
116
|
+
* and hand it off to the sender. Callers should rate-limit this per
|
|
117
|
+
* userId — see Phase 6 rate-limit middleware.
|
|
118
|
+
*/
|
|
119
|
+
send(userId: string, email: string): Promise<void>;
|
|
120
|
+
/**
|
|
121
|
+
* Consume a token. Returns `{ userId, email }` on success, `null` on any
|
|
122
|
+
* failure mode (unknown / expired / already-used / tampered / wrong
|
|
123
|
+
* purpose). Never throws on bad input.
|
|
124
|
+
*
|
|
125
|
+
* NOTE: if `onVerified` throws, the token is already consumed and the
|
|
126
|
+
* error propagates. See the module-level "Idempotency caveat".
|
|
127
|
+
*/
|
|
128
|
+
consume(token: string): Promise<{ userId: string; email: string } | null>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const URL_PLACEHOLDER = "{token}";
|
|
134
|
+
const PURPOSE = "verify-email" as const;
|
|
135
|
+
|
|
136
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Wire up an email-verification flow.
|
|
140
|
+
*
|
|
141
|
+
* @throws {TypeError} Synchronously when `verifyUrlTemplate` is missing the
|
|
142
|
+
* `{token}` placeholder, or when `fromAddress` is empty.
|
|
143
|
+
*/
|
|
144
|
+
export function createEmailVerification(
|
|
145
|
+
options: VerificationFlowOptions,
|
|
146
|
+
): VerificationFlow {
|
|
147
|
+
const { store, sender, fromAddress, verifyUrlTemplate, renderEmail, onVerified } =
|
|
148
|
+
options;
|
|
149
|
+
|
|
150
|
+
if (typeof fromAddress !== "string" || fromAddress.length === 0) {
|
|
151
|
+
throw new TypeError(
|
|
152
|
+
"[@mandujs/core/auth/verification] createEmailVerification: 'fromAddress' is required and must be a non-empty string.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (typeof verifyUrlTemplate !== "string" || !verifyUrlTemplate.includes(URL_PLACEHOLDER)) {
|
|
156
|
+
throw new TypeError(
|
|
157
|
+
`[@mandujs/core/auth/verification] createEmailVerification: 'verifyUrlTemplate' must include the literal '${URL_PLACEHOLDER}' placeholder.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function send(userId: string, email: string): Promise<void> {
|
|
162
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
163
|
+
throw new TypeError(
|
|
164
|
+
"[@mandujs/core/auth/verification] send: userId must be a non-empty string.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (typeof email !== "string" || email.length === 0) {
|
|
168
|
+
throw new TypeError(
|
|
169
|
+
"[@mandujs/core/auth/verification] send: email must be a non-empty string.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Persist the email-under-verification in `meta` so `consume()` can
|
|
174
|
+
// surface it back to `onVerified`. The token itself binds to the userId
|
|
175
|
+
// at the store level; `meta.email` is what the user is CLAIMING to
|
|
176
|
+
// control at send time. They prove control by receiving the link.
|
|
177
|
+
const { token } = await store.mint(PURPOSE, userId, { email });
|
|
178
|
+
|
|
179
|
+
// Base64url-safe nonces mean `encodeURIComponent` is effectively a
|
|
180
|
+
// no-op — we still wrap so a future nonce-alphabet change can't
|
|
181
|
+
// silently produce broken URLs.
|
|
182
|
+
const url = verifyUrlTemplate.replace(URL_PLACEHOLDER, encodeURIComponent(token));
|
|
183
|
+
|
|
184
|
+
const rendered = renderEmail({ url, userId, email });
|
|
185
|
+
if (!rendered || typeof rendered !== "object") {
|
|
186
|
+
throw new TypeError(
|
|
187
|
+
"[@mandujs/core/auth/verification] renderEmail: must return { subject, html?, text? }.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await sender.send({
|
|
192
|
+
from: fromAddress,
|
|
193
|
+
to: email,
|
|
194
|
+
subject: rendered.subject,
|
|
195
|
+
html: rendered.html,
|
|
196
|
+
text: rendered.text,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function consume(
|
|
201
|
+
token: string,
|
|
202
|
+
): Promise<{ userId: string; email: string } | null> {
|
|
203
|
+
// `parseToken` inside the store handles `null`/malformed input by
|
|
204
|
+
// returning null — but our wire layer may have URL-encoded the token,
|
|
205
|
+
// so reverse the encoding we applied in `send()` first. A malformed
|
|
206
|
+
// %-sequence short-circuits to null (never throw on user input).
|
|
207
|
+
const decoded = safeDecodeURIComponent(token);
|
|
208
|
+
if (decoded === null) return null;
|
|
209
|
+
|
|
210
|
+
const record = await store.consume(PURPOSE, decoded);
|
|
211
|
+
if (!record) return null;
|
|
212
|
+
|
|
213
|
+
const email = extractEmail(record);
|
|
214
|
+
if (!email) {
|
|
215
|
+
// Token was valid but the email is missing from meta — indicates a
|
|
216
|
+
// store inconsistency (hand-edited row?). Treat as bogus rather than
|
|
217
|
+
// invoking `onVerified` without an email.
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await onVerified({ userId: record.userId, email });
|
|
222
|
+
return { userId: record.userId, email };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { send, consume };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `decodeURIComponent` throws on malformed `%XX`. User-supplied query
|
|
232
|
+
* strings can carry bad encodings, so we wrap and normalise to null.
|
|
233
|
+
*/
|
|
234
|
+
function safeDecodeURIComponent(value: string): string | null {
|
|
235
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
236
|
+
try {
|
|
237
|
+
return decodeURIComponent(value);
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Pull `email` from `record.meta`. Returns `null` when meta is missing or
|
|
245
|
+
* the key is not a non-empty string. Defensive against a hand-edited DB
|
|
246
|
+
* (or a future migration) where meta ends up with unexpected shape.
|
|
247
|
+
*/
|
|
248
|
+
function extractEmail(record: TokenRecord): string | null {
|
|
249
|
+
const meta = record.meta;
|
|
250
|
+
if (!meta || typeof meta !== "object") return null;
|
|
251
|
+
const raw = meta.email;
|
|
252
|
+
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
253
|
+
}
|