@mandujs/core 0.54.32 → 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 +127 -211
- 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/tokens.ts
CHANGED
|
@@ -1,612 +1,612 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/auth/tokens — internal token store for email verification
|
|
3
|
-
* and password reset flows (Phase 5.3).
|
|
4
|
-
*
|
|
5
|
-
* Tokens are single-use, expiring, and persisted in SQLite. Only a **hash**
|
|
6
|
-
* of the random nonce is stored — the plaintext nonce never touches disk.
|
|
7
|
-
* A leaked row therefore does NOT reveal a token that can be replayed
|
|
8
|
-
* against our `consume()` verifier (the hash is keyed by a secret the
|
|
9
|
-
* caller supplies).
|
|
10
|
-
*
|
|
11
|
-
* ## Wire format
|
|
12
|
-
*
|
|
13
|
-
* `id.nonce` where:
|
|
14
|
-
* - `id` — UUIDv7 (row primary key, scan-friendly)
|
|
15
|
-
* - `nonce` — 32 random bytes, base64url encoded (~43 chars). Never stored
|
|
16
|
-
* plaintext; the row carries `sha256(nonce || "|" || purpose || "|" || secret)`
|
|
17
|
-
* in hex.
|
|
18
|
-
*
|
|
19
|
-
* Base64url is already URL-safe. We still wrap the emitted token in
|
|
20
|
-
* `encodeURIComponent` at the template-render layer (verification.ts /
|
|
21
|
-
* reset.ts) so a future nonce charset change can't silently break link
|
|
22
|
-
* parsing downstream.
|
|
23
|
-
*
|
|
24
|
-
* ## Atomicity
|
|
25
|
-
*
|
|
26
|
-
* `consume()` runs inside a transaction:
|
|
27
|
-
* 1. `SELECT … WHERE id = $1` — load the row under tx
|
|
28
|
-
* 2. Validate purpose / expiry / not-yet-consumed / hash match (constant-time)
|
|
29
|
-
* 3. `UPDATE … SET consumed_at = $now WHERE id = $1 AND consumed_at IS NULL`
|
|
30
|
-
* — the predicate prevents a second concurrent consumer from re-marking
|
|
31
|
-
* 4. Row is only returned to the caller when the UPDATE changed one row
|
|
32
|
-
*
|
|
33
|
-
* Under SQLite WAL with a single writer serialised by the engine, concurrent
|
|
34
|
-
* `consume()` calls on the same token race into the transaction — the second
|
|
35
|
-
* transaction observes `consumed_at IS NOT NULL` and returns null.
|
|
36
|
-
*
|
|
37
|
-
* ## Appendix D compliance
|
|
38
|
-
*
|
|
39
|
-
* - **D.4 WAL**: `PRAGMA journal_mode = WAL` at init, same pattern as
|
|
40
|
-
* `filling/session-sqlite.ts`.
|
|
41
|
-
* - **D.5 createDb routing**: all DB access goes through `@mandujs/core/db`;
|
|
42
|
-
* we never touch `Bun.SQL` directly.
|
|
43
|
-
*
|
|
44
|
-
* @module auth/tokens
|
|
45
|
-
* @internal — Not re-exported from `@mandujs/core/auth`. verification.ts and
|
|
46
|
-
* reset.ts are the public surface; this module is their shared plumbing.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import { createDb, type Db } from "../db/index.js";
|
|
50
|
-
import { newId } from "../id/index.js";
|
|
51
|
-
import { defineCron, type CronRegistration } from "../scheduler/index.js";
|
|
52
|
-
|
|
53
|
-
// ─── Public types ───────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
/** What the token can be consumed for. New purposes require a schema review. */
|
|
56
|
-
export type TokenPurpose = "verify-email" | "reset-password";
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Persisted token record. `tokenHash` is the only identifier-like field that
|
|
60
|
-
* is safe to log — it is not the plaintext nonce.
|
|
61
|
-
*/
|
|
62
|
-
export interface TokenRecord {
|
|
63
|
-
/** UUIDv7 — row primary key, also the first half of the emitted token. */
|
|
64
|
-
id: string;
|
|
65
|
-
userId: string;
|
|
66
|
-
purpose: TokenPurpose;
|
|
67
|
-
/** `sha256(nonce || "|" || purpose || "|" || secret)`, hex-encoded. */
|
|
68
|
-
tokenHash: string;
|
|
69
|
-
/**
|
|
70
|
-
* Purpose-specific sidecar data. For "verify-email" we persist the email
|
|
71
|
-
* being verified; reset tokens typically carry `undefined`.
|
|
72
|
-
*
|
|
73
|
-
* Serialised as JSON in the DB. `null` and `undefined` round-trip as `undefined`.
|
|
74
|
-
*/
|
|
75
|
-
meta?: Record<string, string>;
|
|
76
|
-
/** Unix ms, absolute — compared to `Date.now()` at consume time. */
|
|
77
|
-
expiresAt: number;
|
|
78
|
-
/** Unix ms when `consume()` marked the row used; `null` while still live. */
|
|
79
|
-
consumedAt: number | null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Stored-store contract consumed by verification.ts and reset.ts. */
|
|
83
|
-
export interface AuthTokenStore {
|
|
84
|
-
/**
|
|
85
|
-
* Mint a new token. Inserts a row, returns the plaintext `id.nonce` pair
|
|
86
|
-
* (wire format) plus the record (minus the nonce — {@link TokenRecord.tokenHash}
|
|
87
|
-
* is what was persisted).
|
|
88
|
-
*/
|
|
89
|
-
mint(
|
|
90
|
-
purpose: TokenPurpose,
|
|
91
|
-
userId: string,
|
|
92
|
-
meta?: Record<string, string>,
|
|
93
|
-
): Promise<{ token: string; record: TokenRecord }>;
|
|
94
|
-
/**
|
|
95
|
-
* Atomically validate and consume a token. Returns the record on success.
|
|
96
|
-
* Returns `null` when the token is malformed, unknown, expired, already
|
|
97
|
-
* consumed, wrong-purpose, or the hash fails to verify. **Never throws**
|
|
98
|
-
* on user-supplied values.
|
|
99
|
-
*/
|
|
100
|
-
consume(purpose: TokenPurpose, token: string): Promise<TokenRecord | null>;
|
|
101
|
-
/** Delete expired + already-consumed rows. Returns the deleted count. */
|
|
102
|
-
gcNow(): Promise<number>;
|
|
103
|
-
/** Stop the GC cron (if started) and close the SQLite pool. */
|
|
104
|
-
close(): Promise<void>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Construction options for {@link createAuthTokenStore}. */
|
|
108
|
-
export interface AuthTokenStoreOptions {
|
|
109
|
-
/**
|
|
110
|
-
* HMAC-style keyed SHA-256 secret. The secret is NOT a true HMAC key (we
|
|
111
|
-
* mix it into a hashed suffix rather than using HMAC construction) — the
|
|
112
|
-
* effect is equivalent for our threat model: an attacker who dumps the
|
|
113
|
-
* DB cannot forge a token without also knowing the secret. Recommended
|
|
114
|
-
* length: ≥ 32 bytes of entropy.
|
|
115
|
-
*/
|
|
116
|
-
secret: string;
|
|
117
|
-
/** SQLite path. Default: `.mandu/auth-tokens.db`. */
|
|
118
|
-
dbPath?: string;
|
|
119
|
-
/** Table name. Must match `[A-Za-z_][A-Za-z0-9_]*`. Default: `mandu_auth_tokens`. */
|
|
120
|
-
table?: string;
|
|
121
|
-
/**
|
|
122
|
-
* Per-purpose TTL in seconds. Missing purposes fall back to the built-in
|
|
123
|
-
* default. Built-ins: verify-email=24h, reset-password=1h.
|
|
124
|
-
*/
|
|
125
|
-
ttlSecondsByPurpose?: Partial<Record<TokenPurpose, number>>;
|
|
126
|
-
/**
|
|
127
|
-
* Cron schedule for expired/consumed sweep. Default: `"0 * * * *"` (hourly).
|
|
128
|
-
* Set `false` to disable — callers can still invoke `gcNow()`.
|
|
129
|
-
*/
|
|
130
|
-
gcSchedule?: string | false;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
const DEFAULT_DB_PATH = ".mandu/auth-tokens.db";
|
|
136
|
-
const DEFAULT_TABLE = "mandu_auth_tokens";
|
|
137
|
-
const DEFAULT_GC_SCHEDULE = "0 * * * *";
|
|
138
|
-
|
|
139
|
-
/** 24 hours in seconds — verification links are long-lived. */
|
|
140
|
-
const DEFAULT_TTL_VERIFY_EMAIL = 60 * 60 * 24;
|
|
141
|
-
/** 1 hour in seconds — reset links are short-lived to narrow the leak window. */
|
|
142
|
-
const DEFAULT_TTL_RESET_PASSWORD = 60 * 60;
|
|
143
|
-
|
|
144
|
-
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
145
|
-
|
|
146
|
-
/** 32 bytes of entropy — comfortably above birthday-collision bounds. */
|
|
147
|
-
const NONCE_BYTES = 32;
|
|
148
|
-
|
|
149
|
-
// ─── Crypto helpers ─────────────────────────────────────────────────────────
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Encode bytes as base64url (no padding). URL-safe and shell-safe.
|
|
153
|
-
*
|
|
154
|
-
* `btoa` is our binary-to-base64 primitive; we then translate the +/= alphabet
|
|
155
|
-
* to the URL-safe variant. We avoid `Buffer.from(...).toString("base64url")`
|
|
156
|
-
* so the module stays runtime-portable.
|
|
157
|
-
*/
|
|
158
|
-
function toBase64Url(bytes: Uint8Array): string {
|
|
159
|
-
let binary = "";
|
|
160
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
161
|
-
binary += String.fromCharCode(bytes[i]!);
|
|
162
|
-
}
|
|
163
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Shape of the small subset of `Bun.CryptoHasher` we consume. */
|
|
167
|
-
interface CryptoHasherLike {
|
|
168
|
-
update(input: string | ArrayBufferView | ArrayBuffer): CryptoHasherLike;
|
|
169
|
-
digest(encoding: "hex"): string;
|
|
170
|
-
}
|
|
171
|
-
interface CryptoHasherCtor {
|
|
172
|
-
new (algorithm: "sha256"): CryptoHasherLike;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Resolve `Bun.CryptoHasher` at call time. Falls back to the Web Crypto
|
|
177
|
-
* `subtle.digest` path (async) if Bun isn't present — but the sync fallback
|
|
178
|
-
* below throws and documents the requirement.
|
|
179
|
-
*/
|
|
180
|
-
function getCryptoHasher(): CryptoHasherCtor {
|
|
181
|
-
const g = globalThis as unknown as { Bun?: { CryptoHasher?: CryptoHasherCtor } };
|
|
182
|
-
if (!g.Bun || typeof g.Bun.CryptoHasher !== "function") {
|
|
183
|
-
throw new Error(
|
|
184
|
-
"[@mandujs/core/auth/tokens] Bun.CryptoHasher is unavailable — this module requires the Bun runtime (>= 1.3).",
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
return g.Bun.CryptoHasher;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Hash `nonce` under `purpose` + `secret`. The composition
|
|
192
|
-
* `sha256(nonce + "|" + purpose + "|" + secret)` binds the hash to both a
|
|
193
|
-
* purpose (so a `verify-email` token cannot be replayed into the reset flow)
|
|
194
|
-
* and the server's secret (so a leaked row alone cannot be used to forge).
|
|
195
|
-
*
|
|
196
|
-
* Pipes are deliberate separators — they cannot appear inside base64url
|
|
197
|
-
* nonces, so there is no concatenation ambiguity.
|
|
198
|
-
*/
|
|
199
|
-
function hashNonce(nonce: string, purpose: TokenPurpose, secret: string): string {
|
|
200
|
-
const hasher = new (getCryptoHasher())("sha256");
|
|
201
|
-
hasher.update(nonce);
|
|
202
|
-
hasher.update("|");
|
|
203
|
-
hasher.update(purpose);
|
|
204
|
-
hasher.update("|");
|
|
205
|
-
hasher.update(secret);
|
|
206
|
-
return hasher.digest("hex");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Constant-time string equality. Length mismatch is observed (our hashes are
|
|
211
|
-
* fixed length, so it leaks nothing) — but byte-wise comparison XOR-folds
|
|
212
|
-
* into a single diff bit so the runtime can't short-circuit early.
|
|
213
|
-
*
|
|
214
|
-
* Mirrors the pattern in `middleware/csrf.ts` and `middleware/oauth/index.ts`.
|
|
215
|
-
*/
|
|
216
|
-
function safeEqual(a: string, b: string): boolean {
|
|
217
|
-
if (a.length !== b.length) return false;
|
|
218
|
-
let diff = 0;
|
|
219
|
-
for (let i = 0; i < a.length; i++) {
|
|
220
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
221
|
-
}
|
|
222
|
-
return diff === 0;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Generate {@link NONCE_BYTES} random bytes as a base64url string.
|
|
227
|
-
*
|
|
228
|
-
* `crypto.getRandomValues` is CSPRNG-backed in every supported runtime
|
|
229
|
-
* (Bun ≥ 1.3, Node ≥ 20, browsers, Deno).
|
|
230
|
-
*/
|
|
231
|
-
function generateNonce(): string {
|
|
232
|
-
const bytes = new Uint8Array(NONCE_BYTES);
|
|
233
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
234
|
-
return toBase64Url(bytes);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ─── DB helpers (mirror session-sqlite.ts) ──────────────────────────────────
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* `@mandujs/core/db` is tagged-template first; our DDL/DML strings are
|
|
241
|
-
* dynamic (table name interpolated — SQLite cannot bind identifiers), so we
|
|
242
|
-
* reconstruct a synthetic `TemplateStringsArray` from `$1`/`$2`/… split
|
|
243
|
-
* segments and forward positional params. Lifted verbatim from
|
|
244
|
-
* `filling/session-sqlite.ts`.
|
|
245
|
-
*/
|
|
246
|
-
async function execWithParams(dbOrTx: Db, sql: string, params: unknown[]): Promise<void> {
|
|
247
|
-
const parts = splitPlaceholders(sql, params.length);
|
|
248
|
-
const strings = Object.assign(parts.slice(), {
|
|
249
|
-
raw: parts.slice(),
|
|
250
|
-
}) as unknown as TemplateStringsArray;
|
|
251
|
-
await dbOrTx(strings, ...params);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function queryOne<T extends Record<string, unknown>>(
|
|
255
|
-
dbOrTx: Db,
|
|
256
|
-
sql: string,
|
|
257
|
-
params: unknown[],
|
|
258
|
-
): Promise<T | null> {
|
|
259
|
-
const parts = splitPlaceholders(sql, params.length);
|
|
260
|
-
const strings = Object.assign(parts.slice(), {
|
|
261
|
-
raw: parts.slice(),
|
|
262
|
-
}) as unknown as TemplateStringsArray;
|
|
263
|
-
const rows = await dbOrTx<T>(strings, ...params);
|
|
264
|
-
if (!rows || rows.length === 0) return null;
|
|
265
|
-
return rows[0] as T;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async function execRaw(dbOrTx: Db, sql: string): Promise<void> {
|
|
269
|
-
const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
|
|
270
|
-
await dbOrTx(strings);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function splitPlaceholders(sql: string, expected: number): string[] {
|
|
274
|
-
const parts: string[] = [];
|
|
275
|
-
let rest = sql;
|
|
276
|
-
for (let i = 1; i <= expected; i++) {
|
|
277
|
-
const marker = `$${i}`;
|
|
278
|
-
const idx = rest.indexOf(marker);
|
|
279
|
-
if (idx === -1) {
|
|
280
|
-
throw new Error(
|
|
281
|
-
`[@mandujs/core/auth/tokens] placeholder ${marker} missing in SQL: ${sql}`,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
parts.push(rest.slice(0, idx));
|
|
285
|
-
rest = rest.slice(idx + marker.length);
|
|
286
|
-
}
|
|
287
|
-
parts.push(rest);
|
|
288
|
-
return parts;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
292
|
-
|
|
293
|
-
interface TokenRow {
|
|
294
|
-
id: string;
|
|
295
|
-
user_id: string;
|
|
296
|
-
purpose: string;
|
|
297
|
-
token_hash: string;
|
|
298
|
-
meta: string | null;
|
|
299
|
-
expires_at: number | bigint;
|
|
300
|
-
consumed_at: number | bigint | null;
|
|
301
|
-
[key: string]: unknown;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function rowToRecord(row: TokenRow): TokenRecord {
|
|
305
|
-
let meta: Record<string, string> | undefined;
|
|
306
|
-
if (typeof row.meta === "string" && row.meta.length > 0) {
|
|
307
|
-
try {
|
|
308
|
-
const parsed: unknown = JSON.parse(row.meta);
|
|
309
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
310
|
-
// Force-narrow to Record<string,string> — we wrote it, we know the shape.
|
|
311
|
-
meta = parsed as Record<string, string>;
|
|
312
|
-
}
|
|
313
|
-
} catch {
|
|
314
|
-
// Corrupted row meta — treat as missing. The happy-path writer always
|
|
315
|
-
// emits valid JSON, so this branch only fires on a hand-edited DB.
|
|
316
|
-
meta = undefined;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return {
|
|
320
|
-
id: row.id,
|
|
321
|
-
userId: row.user_id,
|
|
322
|
-
purpose: row.purpose as TokenPurpose,
|
|
323
|
-
tokenHash: row.token_hash,
|
|
324
|
-
meta,
|
|
325
|
-
expiresAt: Number(row.expires_at),
|
|
326
|
-
consumedAt: row.consumed_at === null ? null : Number(row.consumed_at),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ─── Wire format ────────────────────────────────────────────────────────────
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Split the wire-format token into `{ id, nonce }`. Returns `null` on
|
|
334
|
-
* anything malformed — called from `consume()`, which must never throw on
|
|
335
|
-
* user input.
|
|
336
|
-
*
|
|
337
|
-
* The only validation we do is structural: "has exactly one `.` with
|
|
338
|
-
* nonempty sides". We do NOT verify `id` is a UUID here — that would leak
|
|
339
|
-
* "id was a UUID but nonce was wrong" vs "id wasn't a UUID" via branch
|
|
340
|
-
* taken. Both paths fall through to the DB lookup, which returns null for
|
|
341
|
-
* unknown ids uniformly.
|
|
342
|
-
*/
|
|
343
|
-
function parseToken(token: string): { id: string; nonce: string } | null {
|
|
344
|
-
if (typeof token !== "string" || token.length === 0) return null;
|
|
345
|
-
const dot = token.indexOf(".");
|
|
346
|
-
if (dot <= 0 || dot === token.length - 1) return null;
|
|
347
|
-
// Reject multi-dot tokens — base64url doesn't produce dots, and UUIDv7
|
|
348
|
-
// doesn't either. A stray extra dot means "tampered" → null.
|
|
349
|
-
if (token.indexOf(".", dot + 1) !== -1) return null;
|
|
350
|
-
return { id: token.slice(0, dot), nonce: token.slice(dot + 1) };
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Build a token store backed by SQLite. Initialisation is lazy — the DB
|
|
357
|
-
* connection and schema are created on first use, matching the pattern in
|
|
358
|
-
* `filling/session-sqlite.ts` so boot stays cheap.
|
|
359
|
-
*
|
|
360
|
-
* @throws {TypeError} Synchronously when `secret` is empty or `table` fails
|
|
361
|
-
* the safe-identifier check.
|
|
362
|
-
*/
|
|
363
|
-
export function createAuthTokenStore(options: AuthTokenStoreOptions): AuthTokenStore {
|
|
364
|
-
const {
|
|
365
|
-
secret,
|
|
366
|
-
dbPath = DEFAULT_DB_PATH,
|
|
367
|
-
table = DEFAULT_TABLE,
|
|
368
|
-
ttlSecondsByPurpose,
|
|
369
|
-
gcSchedule = DEFAULT_GC_SCHEDULE,
|
|
370
|
-
} = options;
|
|
371
|
-
|
|
372
|
-
if (typeof secret !== "string" || secret.length === 0) {
|
|
373
|
-
throw new TypeError(
|
|
374
|
-
"[@mandujs/core/auth/tokens] createAuthTokenStore: 'secret' is required and must be a non-empty string.",
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
if (!SAFE_IDENT_RE.test(table)) {
|
|
378
|
-
throw new TypeError(
|
|
379
|
-
`[@mandujs/core/auth/tokens] Invalid table name ${JSON.stringify(table)}. ` +
|
|
380
|
-
`Must match ${SAFE_IDENT_RE}.`,
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const ttlByPurpose: Record<TokenPurpose, number> = {
|
|
385
|
-
"verify-email": ttlSecondsByPurpose?.["verify-email"] ?? DEFAULT_TTL_VERIFY_EMAIL,
|
|
386
|
-
"reset-password": ttlSecondsByPurpose?.["reset-password"] ?? DEFAULT_TTL_RESET_PASSWORD,
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
const url = `sqlite:${dbPath}`;
|
|
390
|
-
const db: Db = createDb({ url });
|
|
391
|
-
|
|
392
|
-
let initPromise: Promise<void> | null = null;
|
|
393
|
-
let closed = false;
|
|
394
|
-
let cronReg: CronRegistration | null = null;
|
|
395
|
-
|
|
396
|
-
function ensureInit(): Promise<void> {
|
|
397
|
-
if (initPromise) return initPromise;
|
|
398
|
-
initPromise = (async () => {
|
|
399
|
-
// D.4: enable WAL so the GC sweep + live writers don't block each other.
|
|
400
|
-
// :memory: accepts the pragma and silently stays in-memory — matches
|
|
401
|
-
// session-sqlite.ts.
|
|
402
|
-
await db`PRAGMA journal_mode = WAL`;
|
|
403
|
-
|
|
404
|
-
const createTableSql = `CREATE TABLE IF NOT EXISTS ${table} (
|
|
405
|
-
id TEXT PRIMARY KEY,
|
|
406
|
-
user_id TEXT NOT NULL,
|
|
407
|
-
purpose TEXT NOT NULL,
|
|
408
|
-
token_hash TEXT NOT NULL,
|
|
409
|
-
meta TEXT,
|
|
410
|
-
expires_at INTEGER NOT NULL,
|
|
411
|
-
consumed_at INTEGER
|
|
412
|
-
)`;
|
|
413
|
-
const createUserIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_user ON ${table}(user_id, purpose)`;
|
|
414
|
-
const createExpiresIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_expires ON ${table}(expires_at)`;
|
|
415
|
-
|
|
416
|
-
await execRaw(db, createTableSql);
|
|
417
|
-
await execRaw(db, createUserIndexSql);
|
|
418
|
-
await execRaw(db, createExpiresIndexSql);
|
|
419
|
-
})();
|
|
420
|
-
return initPromise;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function startCronIfEnabled(): void {
|
|
424
|
-
if (gcSchedule === false) return;
|
|
425
|
-
if (cronReg) return;
|
|
426
|
-
try {
|
|
427
|
-
const reg = defineCron({
|
|
428
|
-
[`${table}:gc`]: {
|
|
429
|
-
schedule: gcSchedule,
|
|
430
|
-
run: async () => {
|
|
431
|
-
// Swallow errors at the cron boundary — a stuck GC must not
|
|
432
|
-
// crash the process. The scheduler already logs thrown errors,
|
|
433
|
-
// but we also don't want a transient DB glitch to propagate.
|
|
434
|
-
try {
|
|
435
|
-
await gcNow();
|
|
436
|
-
} catch (err) {
|
|
437
|
-
console.warn(
|
|
438
|
-
`[@mandujs/core/auth/tokens] GC sweep failed: ${
|
|
439
|
-
err instanceof Error ? err.message : String(err)
|
|
440
|
-
}`,
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
},
|
|
444
|
-
},
|
|
445
|
-
});
|
|
446
|
-
reg.start();
|
|
447
|
-
cronReg = reg;
|
|
448
|
-
} catch (err) {
|
|
449
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
450
|
-
console.warn(
|
|
451
|
-
`[@mandujs/core/auth/tokens] GC cron disabled: ${msg}. ` +
|
|
452
|
-
`Call store.gcNow() manually if needed.`,
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Fire-and-forget init + cron wiring — any error surfaces on the first
|
|
458
|
-
// real call. Mirrors session-sqlite.ts.
|
|
459
|
-
void ensureInit().then(startCronIfEnabled);
|
|
460
|
-
|
|
461
|
-
// ─── mint ─────────────────────────────────────────────────────────────────
|
|
462
|
-
|
|
463
|
-
async function mint(
|
|
464
|
-
purpose: TokenPurpose,
|
|
465
|
-
userId: string,
|
|
466
|
-
meta?: Record<string, string>,
|
|
467
|
-
): Promise<{ token: string; record: TokenRecord }> {
|
|
468
|
-
if (closed) {
|
|
469
|
-
throw new Error("[@mandujs/core/auth/tokens] store is closed.");
|
|
470
|
-
}
|
|
471
|
-
if (typeof userId !== "string" || userId.length === 0) {
|
|
472
|
-
throw new TypeError(
|
|
473
|
-
"[@mandujs/core/auth/tokens] mint: userId must be a non-empty string.",
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
await ensureInit();
|
|
477
|
-
|
|
478
|
-
const id = newId();
|
|
479
|
-
const nonce = generateNonce();
|
|
480
|
-
const tokenHash = hashNonce(nonce, purpose, secret);
|
|
481
|
-
const expiresAt = Date.now() + ttlByPurpose[purpose] * 1000;
|
|
482
|
-
const metaJson =
|
|
483
|
-
meta && Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
|
|
484
|
-
|
|
485
|
-
const sql = `INSERT INTO ${table} (id, user_id, purpose, token_hash, meta, expires_at, consumed_at) VALUES ($1, $2, $3, $4, $5, $6, NULL)`;
|
|
486
|
-
await execWithParams(db, sql, [id, userId, purpose, tokenHash, metaJson, expiresAt]);
|
|
487
|
-
|
|
488
|
-
const record: TokenRecord = {
|
|
489
|
-
id,
|
|
490
|
-
userId,
|
|
491
|
-
purpose,
|
|
492
|
-
tokenHash,
|
|
493
|
-
meta: meta && Object.keys(meta).length > 0 ? { ...meta } : undefined,
|
|
494
|
-
expiresAt,
|
|
495
|
-
consumedAt: null,
|
|
496
|
-
};
|
|
497
|
-
return { token: `${id}.${nonce}`, record };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// ─── consume ──────────────────────────────────────────────────────────────
|
|
501
|
-
|
|
502
|
-
async function consume(
|
|
503
|
-
purpose: TokenPurpose,
|
|
504
|
-
token: string,
|
|
505
|
-
): Promise<TokenRecord | null> {
|
|
506
|
-
if (closed) {
|
|
507
|
-
throw new Error("[@mandujs/core/auth/tokens] store is closed.");
|
|
508
|
-
}
|
|
509
|
-
// Validate BEFORE ensureInit — malformed tokens are cheap to reject
|
|
510
|
-
// without opening the DB. But we still need init for the DB path below.
|
|
511
|
-
const parsed = parseToken(token);
|
|
512
|
-
if (!parsed) return null;
|
|
513
|
-
await ensureInit();
|
|
514
|
-
|
|
515
|
-
const now = Date.now();
|
|
516
|
-
const expectedHash = hashNonce(parsed.nonce, purpose, secret);
|
|
517
|
-
|
|
518
|
-
// Wrap in a transaction so the SELECT and the consuming UPDATE cannot be
|
|
519
|
-
// interleaved by a second consumer. Under WAL with SQLite's single-
|
|
520
|
-
// writer-serialised model, the second tx blocks on the first and then
|
|
521
|
-
// observes `consumed_at IS NOT NULL`.
|
|
522
|
-
let result: TokenRecord | null = null;
|
|
523
|
-
await db.transaction(async (tx) => {
|
|
524
|
-
const row = await queryOne<TokenRow>(
|
|
525
|
-
tx,
|
|
526
|
-
`SELECT id, user_id, purpose, token_hash, meta, expires_at, consumed_at FROM ${table} WHERE id = $1 LIMIT 1`,
|
|
527
|
-
[parsed.id],
|
|
528
|
-
);
|
|
529
|
-
if (!row) return; // unknown id
|
|
530
|
-
|
|
531
|
-
// Collapse every validation failure into "return null" without
|
|
532
|
-
// revealing which check fired. Order doesn't matter for correctness
|
|
533
|
-
// but we do the cheap checks first to keep the hot path fast.
|
|
534
|
-
if (row.purpose !== purpose) return;
|
|
535
|
-
if (Number(row.expires_at) <= now) return;
|
|
536
|
-
if (row.consumed_at !== null) return;
|
|
537
|
-
if (!safeEqual(row.token_hash, expectedHash)) return;
|
|
538
|
-
|
|
539
|
-
// Conditional UPDATE — the `consumed_at IS NULL` predicate is the
|
|
540
|
-
// atomic guard against a racing consumer. Even if two transactions
|
|
541
|
-
// both passed the SELECT (which WAL prevents at the single-writer
|
|
542
|
-
// level, but we keep the belt for correctness), only one UPDATE
|
|
543
|
-
// changes a row.
|
|
544
|
-
await execWithParams(
|
|
545
|
-
tx,
|
|
546
|
-
`UPDATE ${table} SET consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL`,
|
|
547
|
-
[now, row.id],
|
|
548
|
-
);
|
|
549
|
-
|
|
550
|
-
// Re-read the row to confirm we won the race AND to return the
|
|
551
|
-
// authoritative state. A concurrent consumer would have flipped
|
|
552
|
-
// `consumed_at` to some other timestamp between our check and update
|
|
553
|
-
// — we cross-check by comparing back.
|
|
554
|
-
const updated = await queryOne<TokenRow>(
|
|
555
|
-
tx,
|
|
556
|
-
`SELECT id, user_id, purpose, token_hash, meta, expires_at, consumed_at FROM ${table} WHERE id = $1 LIMIT 1`,
|
|
557
|
-
[row.id],
|
|
558
|
-
);
|
|
559
|
-
if (!updated || updated.consumed_at === null) {
|
|
560
|
-
// Either vanished (impossible in a tx) or the UPDATE didn't land
|
|
561
|
-
// (should be impossible given our IS NULL guard) — fall through
|
|
562
|
-
// to null.
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
// If another tx wrote a different `consumed_at`, concede and return
|
|
566
|
-
// null — we lost the race.
|
|
567
|
-
if (Number(updated.consumed_at) !== now) {
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
result = rowToRecord(updated);
|
|
571
|
-
});
|
|
572
|
-
return result;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// ─── gcNow ────────────────────────────────────────────────────────────────
|
|
576
|
-
|
|
577
|
-
async function gcNow(): Promise<number> {
|
|
578
|
-
if (closed) {
|
|
579
|
-
throw new Error("[@mandujs/core/auth/tokens] store is closed.");
|
|
580
|
-
}
|
|
581
|
-
await ensureInit();
|
|
582
|
-
|
|
583
|
-
const now = Date.now();
|
|
584
|
-
let deleted = 0;
|
|
585
|
-
await db.transaction(async (tx) => {
|
|
586
|
-
const countSql = `SELECT COUNT(*) AS n FROM ${table} WHERE expires_at <= $1 OR consumed_at IS NOT NULL`;
|
|
587
|
-
const cnt = await queryOne<{ n: number | bigint }>(tx, countSql, [now]);
|
|
588
|
-
deleted = cnt ? Number(cnt.n) : 0;
|
|
589
|
-
const delSql = `DELETE FROM ${table} WHERE expires_at <= $1 OR consumed_at IS NOT NULL`;
|
|
590
|
-
await execWithParams(tx, delSql, [now]);
|
|
591
|
-
});
|
|
592
|
-
return deleted;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// ─── close ────────────────────────────────────────────────────────────────
|
|
596
|
-
|
|
597
|
-
async function close(): Promise<void> {
|
|
598
|
-
if (closed) return;
|
|
599
|
-
closed = true;
|
|
600
|
-
if (cronReg) {
|
|
601
|
-
try {
|
|
602
|
-
await cronReg.stop();
|
|
603
|
-
} catch {
|
|
604
|
-
// Best-effort shutdown — don't mask the caller's flow.
|
|
605
|
-
}
|
|
606
|
-
cronReg = null;
|
|
607
|
-
}
|
|
608
|
-
await db.close();
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
return { mint, consume, gcNow, close };
|
|
612
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/tokens — internal token store for email verification
|
|
3
|
+
* and password reset flows (Phase 5.3).
|
|
4
|
+
*
|
|
5
|
+
* Tokens are single-use, expiring, and persisted in SQLite. Only a **hash**
|
|
6
|
+
* of the random nonce is stored — the plaintext nonce never touches disk.
|
|
7
|
+
* A leaked row therefore does NOT reveal a token that can be replayed
|
|
8
|
+
* against our `consume()` verifier (the hash is keyed by a secret the
|
|
9
|
+
* caller supplies).
|
|
10
|
+
*
|
|
11
|
+
* ## Wire format
|
|
12
|
+
*
|
|
13
|
+
* `id.nonce` where:
|
|
14
|
+
* - `id` — UUIDv7 (row primary key, scan-friendly)
|
|
15
|
+
* - `nonce` — 32 random bytes, base64url encoded (~43 chars). Never stored
|
|
16
|
+
* plaintext; the row carries `sha256(nonce || "|" || purpose || "|" || secret)`
|
|
17
|
+
* in hex.
|
|
18
|
+
*
|
|
19
|
+
* Base64url is already URL-safe. We still wrap the emitted token in
|
|
20
|
+
* `encodeURIComponent` at the template-render layer (verification.ts /
|
|
21
|
+
* reset.ts) so a future nonce charset change can't silently break link
|
|
22
|
+
* parsing downstream.
|
|
23
|
+
*
|
|
24
|
+
* ## Atomicity
|
|
25
|
+
*
|
|
26
|
+
* `consume()` runs inside a transaction:
|
|
27
|
+
* 1. `SELECT … WHERE id = $1` — load the row under tx
|
|
28
|
+
* 2. Validate purpose / expiry / not-yet-consumed / hash match (constant-time)
|
|
29
|
+
* 3. `UPDATE … SET consumed_at = $now WHERE id = $1 AND consumed_at IS NULL`
|
|
30
|
+
* — the predicate prevents a second concurrent consumer from re-marking
|
|
31
|
+
* 4. Row is only returned to the caller when the UPDATE changed one row
|
|
32
|
+
*
|
|
33
|
+
* Under SQLite WAL with a single writer serialised by the engine, concurrent
|
|
34
|
+
* `consume()` calls on the same token race into the transaction — the second
|
|
35
|
+
* transaction observes `consumed_at IS NOT NULL` and returns null.
|
|
36
|
+
*
|
|
37
|
+
* ## Appendix D compliance
|
|
38
|
+
*
|
|
39
|
+
* - **D.4 WAL**: `PRAGMA journal_mode = WAL` at init, same pattern as
|
|
40
|
+
* `filling/session-sqlite.ts`.
|
|
41
|
+
* - **D.5 createDb routing**: all DB access goes through `@mandujs/core/db`;
|
|
42
|
+
* we never touch `Bun.SQL` directly.
|
|
43
|
+
*
|
|
44
|
+
* @module auth/tokens
|
|
45
|
+
* @internal — Not re-exported from `@mandujs/core/auth`. verification.ts and
|
|
46
|
+
* reset.ts are the public surface; this module is their shared plumbing.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { createDb, type Db } from "../db/index.js";
|
|
50
|
+
import { newId } from "../id/index.js";
|
|
51
|
+
import { defineCron, type CronRegistration } from "../scheduler/index.js";
|
|
52
|
+
|
|
53
|
+
// ─── Public types ───────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** What the token can be consumed for. New purposes require a schema review. */
|
|
56
|
+
export type TokenPurpose = "verify-email" | "reset-password";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Persisted token record. `tokenHash` is the only identifier-like field that
|
|
60
|
+
* is safe to log — it is not the plaintext nonce.
|
|
61
|
+
*/
|
|
62
|
+
export interface TokenRecord {
|
|
63
|
+
/** UUIDv7 — row primary key, also the first half of the emitted token. */
|
|
64
|
+
id: string;
|
|
65
|
+
userId: string;
|
|
66
|
+
purpose: TokenPurpose;
|
|
67
|
+
/** `sha256(nonce || "|" || purpose || "|" || secret)`, hex-encoded. */
|
|
68
|
+
tokenHash: string;
|
|
69
|
+
/**
|
|
70
|
+
* Purpose-specific sidecar data. For "verify-email" we persist the email
|
|
71
|
+
* being verified; reset tokens typically carry `undefined`.
|
|
72
|
+
*
|
|
73
|
+
* Serialised as JSON in the DB. `null` and `undefined` round-trip as `undefined`.
|
|
74
|
+
*/
|
|
75
|
+
meta?: Record<string, string>;
|
|
76
|
+
/** Unix ms, absolute — compared to `Date.now()` at consume time. */
|
|
77
|
+
expiresAt: number;
|
|
78
|
+
/** Unix ms when `consume()` marked the row used; `null` while still live. */
|
|
79
|
+
consumedAt: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stored-store contract consumed by verification.ts and reset.ts. */
|
|
83
|
+
export interface AuthTokenStore {
|
|
84
|
+
/**
|
|
85
|
+
* Mint a new token. Inserts a row, returns the plaintext `id.nonce` pair
|
|
86
|
+
* (wire format) plus the record (minus the nonce — {@link TokenRecord.tokenHash}
|
|
87
|
+
* is what was persisted).
|
|
88
|
+
*/
|
|
89
|
+
mint(
|
|
90
|
+
purpose: TokenPurpose,
|
|
91
|
+
userId: string,
|
|
92
|
+
meta?: Record<string, string>,
|
|
93
|
+
): Promise<{ token: string; record: TokenRecord }>;
|
|
94
|
+
/**
|
|
95
|
+
* Atomically validate and consume a token. Returns the record on success.
|
|
96
|
+
* Returns `null` when the token is malformed, unknown, expired, already
|
|
97
|
+
* consumed, wrong-purpose, or the hash fails to verify. **Never throws**
|
|
98
|
+
* on user-supplied values.
|
|
99
|
+
*/
|
|
100
|
+
consume(purpose: TokenPurpose, token: string): Promise<TokenRecord | null>;
|
|
101
|
+
/** Delete expired + already-consumed rows. Returns the deleted count. */
|
|
102
|
+
gcNow(): Promise<number>;
|
|
103
|
+
/** Stop the GC cron (if started) and close the SQLite pool. */
|
|
104
|
+
close(): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Construction options for {@link createAuthTokenStore}. */
|
|
108
|
+
export interface AuthTokenStoreOptions {
|
|
109
|
+
/**
|
|
110
|
+
* HMAC-style keyed SHA-256 secret. The secret is NOT a true HMAC key (we
|
|
111
|
+
* mix it into a hashed suffix rather than using HMAC construction) — the
|
|
112
|
+
* effect is equivalent for our threat model: an attacker who dumps the
|
|
113
|
+
* DB cannot forge a token without also knowing the secret. Recommended
|
|
114
|
+
* length: ≥ 32 bytes of entropy.
|
|
115
|
+
*/
|
|
116
|
+
secret: string;
|
|
117
|
+
/** SQLite path. Default: `.mandu/auth-tokens.db`. */
|
|
118
|
+
dbPath?: string;
|
|
119
|
+
/** Table name. Must match `[A-Za-z_][A-Za-z0-9_]*`. Default: `mandu_auth_tokens`. */
|
|
120
|
+
table?: string;
|
|
121
|
+
/**
|
|
122
|
+
* Per-purpose TTL in seconds. Missing purposes fall back to the built-in
|
|
123
|
+
* default. Built-ins: verify-email=24h, reset-password=1h.
|
|
124
|
+
*/
|
|
125
|
+
ttlSecondsByPurpose?: Partial<Record<TokenPurpose, number>>;
|
|
126
|
+
/**
|
|
127
|
+
* Cron schedule for expired/consumed sweep. Default: `"0 * * * *"` (hourly).
|
|
128
|
+
* Set `false` to disable — callers can still invoke `gcNow()`.
|
|
129
|
+
*/
|
|
130
|
+
gcSchedule?: string | false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const DEFAULT_DB_PATH = ".mandu/auth-tokens.db";
|
|
136
|
+
const DEFAULT_TABLE = "mandu_auth_tokens";
|
|
137
|
+
const DEFAULT_GC_SCHEDULE = "0 * * * *";
|
|
138
|
+
|
|
139
|
+
/** 24 hours in seconds — verification links are long-lived. */
|
|
140
|
+
const DEFAULT_TTL_VERIFY_EMAIL = 60 * 60 * 24;
|
|
141
|
+
/** 1 hour in seconds — reset links are short-lived to narrow the leak window. */
|
|
142
|
+
const DEFAULT_TTL_RESET_PASSWORD = 60 * 60;
|
|
143
|
+
|
|
144
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
145
|
+
|
|
146
|
+
/** 32 bytes of entropy — comfortably above birthday-collision bounds. */
|
|
147
|
+
const NONCE_BYTES = 32;
|
|
148
|
+
|
|
149
|
+
// ─── Crypto helpers ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Encode bytes as base64url (no padding). URL-safe and shell-safe.
|
|
153
|
+
*
|
|
154
|
+
* `btoa` is our binary-to-base64 primitive; we then translate the +/= alphabet
|
|
155
|
+
* to the URL-safe variant. We avoid `Buffer.from(...).toString("base64url")`
|
|
156
|
+
* so the module stays runtime-portable.
|
|
157
|
+
*/
|
|
158
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
159
|
+
let binary = "";
|
|
160
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
161
|
+
binary += String.fromCharCode(bytes[i]!);
|
|
162
|
+
}
|
|
163
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Shape of the small subset of `Bun.CryptoHasher` we consume. */
|
|
167
|
+
interface CryptoHasherLike {
|
|
168
|
+
update(input: string | ArrayBufferView | ArrayBuffer): CryptoHasherLike;
|
|
169
|
+
digest(encoding: "hex"): string;
|
|
170
|
+
}
|
|
171
|
+
interface CryptoHasherCtor {
|
|
172
|
+
new (algorithm: "sha256"): CryptoHasherLike;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve `Bun.CryptoHasher` at call time. Falls back to the Web Crypto
|
|
177
|
+
* `subtle.digest` path (async) if Bun isn't present — but the sync fallback
|
|
178
|
+
* below throws and documents the requirement.
|
|
179
|
+
*/
|
|
180
|
+
function getCryptoHasher(): CryptoHasherCtor {
|
|
181
|
+
const g = globalThis as unknown as { Bun?: { CryptoHasher?: CryptoHasherCtor } };
|
|
182
|
+
if (!g.Bun || typeof g.Bun.CryptoHasher !== "function") {
|
|
183
|
+
throw new Error(
|
|
184
|
+
"[@mandujs/core/auth/tokens] Bun.CryptoHasher is unavailable — this module requires the Bun runtime (>= 1.3).",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return g.Bun.CryptoHasher;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Hash `nonce` under `purpose` + `secret`. The composition
|
|
192
|
+
* `sha256(nonce + "|" + purpose + "|" + secret)` binds the hash to both a
|
|
193
|
+
* purpose (so a `verify-email` token cannot be replayed into the reset flow)
|
|
194
|
+
* and the server's secret (so a leaked row alone cannot be used to forge).
|
|
195
|
+
*
|
|
196
|
+
* Pipes are deliberate separators — they cannot appear inside base64url
|
|
197
|
+
* nonces, so there is no concatenation ambiguity.
|
|
198
|
+
*/
|
|
199
|
+
function hashNonce(nonce: string, purpose: TokenPurpose, secret: string): string {
|
|
200
|
+
const hasher = new (getCryptoHasher())("sha256");
|
|
201
|
+
hasher.update(nonce);
|
|
202
|
+
hasher.update("|");
|
|
203
|
+
hasher.update(purpose);
|
|
204
|
+
hasher.update("|");
|
|
205
|
+
hasher.update(secret);
|
|
206
|
+
return hasher.digest("hex");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Constant-time string equality. Length mismatch is observed (our hashes are
|
|
211
|
+
* fixed length, so it leaks nothing) — but byte-wise comparison XOR-folds
|
|
212
|
+
* into a single diff bit so the runtime can't short-circuit early.
|
|
213
|
+
*
|
|
214
|
+
* Mirrors the pattern in `middleware/csrf.ts` and `middleware/oauth/index.ts`.
|
|
215
|
+
*/
|
|
216
|
+
function safeEqual(a: string, b: string): boolean {
|
|
217
|
+
if (a.length !== b.length) return false;
|
|
218
|
+
let diff = 0;
|
|
219
|
+
for (let i = 0; i < a.length; i++) {
|
|
220
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
221
|
+
}
|
|
222
|
+
return diff === 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Generate {@link NONCE_BYTES} random bytes as a base64url string.
|
|
227
|
+
*
|
|
228
|
+
* `crypto.getRandomValues` is CSPRNG-backed in every supported runtime
|
|
229
|
+
* (Bun ≥ 1.3, Node ≥ 20, browsers, Deno).
|
|
230
|
+
*/
|
|
231
|
+
function generateNonce(): string {
|
|
232
|
+
const bytes = new Uint8Array(NONCE_BYTES);
|
|
233
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
234
|
+
return toBase64Url(bytes);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── DB helpers (mirror session-sqlite.ts) ──────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* `@mandujs/core/db` is tagged-template first; our DDL/DML strings are
|
|
241
|
+
* dynamic (table name interpolated — SQLite cannot bind identifiers), so we
|
|
242
|
+
* reconstruct a synthetic `TemplateStringsArray` from `$1`/`$2`/… split
|
|
243
|
+
* segments and forward positional params. Lifted verbatim from
|
|
244
|
+
* `filling/session-sqlite.ts`.
|
|
245
|
+
*/
|
|
246
|
+
async function execWithParams(dbOrTx: Db, sql: string, params: unknown[]): Promise<void> {
|
|
247
|
+
const parts = splitPlaceholders(sql, params.length);
|
|
248
|
+
const strings = Object.assign(parts.slice(), {
|
|
249
|
+
raw: parts.slice(),
|
|
250
|
+
}) as unknown as TemplateStringsArray;
|
|
251
|
+
await dbOrTx(strings, ...params);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function queryOne<T extends Record<string, unknown>>(
|
|
255
|
+
dbOrTx: Db,
|
|
256
|
+
sql: string,
|
|
257
|
+
params: unknown[],
|
|
258
|
+
): Promise<T | null> {
|
|
259
|
+
const parts = splitPlaceholders(sql, params.length);
|
|
260
|
+
const strings = Object.assign(parts.slice(), {
|
|
261
|
+
raw: parts.slice(),
|
|
262
|
+
}) as unknown as TemplateStringsArray;
|
|
263
|
+
const rows = await dbOrTx<T>(strings, ...params);
|
|
264
|
+
if (!rows || rows.length === 0) return null;
|
|
265
|
+
return rows[0] as T;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function execRaw(dbOrTx: Db, sql: string): Promise<void> {
|
|
269
|
+
const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
|
|
270
|
+
await dbOrTx(strings);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function splitPlaceholders(sql: string, expected: number): string[] {
|
|
274
|
+
const parts: string[] = [];
|
|
275
|
+
let rest = sql;
|
|
276
|
+
for (let i = 1; i <= expected; i++) {
|
|
277
|
+
const marker = `$${i}`;
|
|
278
|
+
const idx = rest.indexOf(marker);
|
|
279
|
+
if (idx === -1) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`[@mandujs/core/auth/tokens] placeholder ${marker} missing in SQL: ${sql}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
parts.push(rest.slice(0, idx));
|
|
285
|
+
rest = rest.slice(idx + marker.length);
|
|
286
|
+
}
|
|
287
|
+
parts.push(rest);
|
|
288
|
+
return parts;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
interface TokenRow {
|
|
294
|
+
id: string;
|
|
295
|
+
user_id: string;
|
|
296
|
+
purpose: string;
|
|
297
|
+
token_hash: string;
|
|
298
|
+
meta: string | null;
|
|
299
|
+
expires_at: number | bigint;
|
|
300
|
+
consumed_at: number | bigint | null;
|
|
301
|
+
[key: string]: unknown;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function rowToRecord(row: TokenRow): TokenRecord {
|
|
305
|
+
let meta: Record<string, string> | undefined;
|
|
306
|
+
if (typeof row.meta === "string" && row.meta.length > 0) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed: unknown = JSON.parse(row.meta);
|
|
309
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
310
|
+
// Force-narrow to Record<string,string> — we wrote it, we know the shape.
|
|
311
|
+
meta = parsed as Record<string, string>;
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// Corrupted row meta — treat as missing. The happy-path writer always
|
|
315
|
+
// emits valid JSON, so this branch only fires on a hand-edited DB.
|
|
316
|
+
meta = undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
id: row.id,
|
|
321
|
+
userId: row.user_id,
|
|
322
|
+
purpose: row.purpose as TokenPurpose,
|
|
323
|
+
tokenHash: row.token_hash,
|
|
324
|
+
meta,
|
|
325
|
+
expiresAt: Number(row.expires_at),
|
|
326
|
+
consumedAt: row.consumed_at === null ? null : Number(row.consumed_at),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Wire format ────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Split the wire-format token into `{ id, nonce }`. Returns `null` on
|
|
334
|
+
* anything malformed — called from `consume()`, which must never throw on
|
|
335
|
+
* user input.
|
|
336
|
+
*
|
|
337
|
+
* The only validation we do is structural: "has exactly one `.` with
|
|
338
|
+
* nonempty sides". We do NOT verify `id` is a UUID here — that would leak
|
|
339
|
+
* "id was a UUID but nonce was wrong" vs "id wasn't a UUID" via branch
|
|
340
|
+
* taken. Both paths fall through to the DB lookup, which returns null for
|
|
341
|
+
* unknown ids uniformly.
|
|
342
|
+
*/
|
|
343
|
+
function parseToken(token: string): { id: string; nonce: string } | null {
|
|
344
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
345
|
+
const dot = token.indexOf(".");
|
|
346
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
347
|
+
// Reject multi-dot tokens — base64url doesn't produce dots, and UUIDv7
|
|
348
|
+
// doesn't either. A stray extra dot means "tampered" → null.
|
|
349
|
+
if (token.indexOf(".", dot + 1) !== -1) return null;
|
|
350
|
+
return { id: token.slice(0, dot), nonce: token.slice(dot + 1) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Build a token store backed by SQLite. Initialisation is lazy — the DB
|
|
357
|
+
* connection and schema are created on first use, matching the pattern in
|
|
358
|
+
* `filling/session-sqlite.ts` so boot stays cheap.
|
|
359
|
+
*
|
|
360
|
+
* @throws {TypeError} Synchronously when `secret` is empty or `table` fails
|
|
361
|
+
* the safe-identifier check.
|
|
362
|
+
*/
|
|
363
|
+
export function createAuthTokenStore(options: AuthTokenStoreOptions): AuthTokenStore {
|
|
364
|
+
const {
|
|
365
|
+
secret,
|
|
366
|
+
dbPath = DEFAULT_DB_PATH,
|
|
367
|
+
table = DEFAULT_TABLE,
|
|
368
|
+
ttlSecondsByPurpose,
|
|
369
|
+
gcSchedule = DEFAULT_GC_SCHEDULE,
|
|
370
|
+
} = options;
|
|
371
|
+
|
|
372
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
373
|
+
throw new TypeError(
|
|
374
|
+
"[@mandujs/core/auth/tokens] createAuthTokenStore: 'secret' is required and must be a non-empty string.",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (!SAFE_IDENT_RE.test(table)) {
|
|
378
|
+
throw new TypeError(
|
|
379
|
+
`[@mandujs/core/auth/tokens] Invalid table name ${JSON.stringify(table)}. ` +
|
|
380
|
+
`Must match ${SAFE_IDENT_RE}.`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const ttlByPurpose: Record<TokenPurpose, number> = {
|
|
385
|
+
"verify-email": ttlSecondsByPurpose?.["verify-email"] ?? DEFAULT_TTL_VERIFY_EMAIL,
|
|
386
|
+
"reset-password": ttlSecondsByPurpose?.["reset-password"] ?? DEFAULT_TTL_RESET_PASSWORD,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const url = `sqlite:${dbPath}`;
|
|
390
|
+
const db: Db = createDb({ url });
|
|
391
|
+
|
|
392
|
+
let initPromise: Promise<void> | null = null;
|
|
393
|
+
let closed = false;
|
|
394
|
+
let cronReg: CronRegistration | null = null;
|
|
395
|
+
|
|
396
|
+
function ensureInit(): Promise<void> {
|
|
397
|
+
if (initPromise) return initPromise;
|
|
398
|
+
initPromise = (async () => {
|
|
399
|
+
// D.4: enable WAL so the GC sweep + live writers don't block each other.
|
|
400
|
+
// :memory: accepts the pragma and silently stays in-memory — matches
|
|
401
|
+
// session-sqlite.ts.
|
|
402
|
+
await db`PRAGMA journal_mode = WAL`;
|
|
403
|
+
|
|
404
|
+
const createTableSql = `CREATE TABLE IF NOT EXISTS ${table} (
|
|
405
|
+
id TEXT PRIMARY KEY,
|
|
406
|
+
user_id TEXT NOT NULL,
|
|
407
|
+
purpose TEXT NOT NULL,
|
|
408
|
+
token_hash TEXT NOT NULL,
|
|
409
|
+
meta TEXT,
|
|
410
|
+
expires_at INTEGER NOT NULL,
|
|
411
|
+
consumed_at INTEGER
|
|
412
|
+
)`;
|
|
413
|
+
const createUserIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_user ON ${table}(user_id, purpose)`;
|
|
414
|
+
const createExpiresIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_expires ON ${table}(expires_at)`;
|
|
415
|
+
|
|
416
|
+
await execRaw(db, createTableSql);
|
|
417
|
+
await execRaw(db, createUserIndexSql);
|
|
418
|
+
await execRaw(db, createExpiresIndexSql);
|
|
419
|
+
})();
|
|
420
|
+
return initPromise;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function startCronIfEnabled(): void {
|
|
424
|
+
if (gcSchedule === false) return;
|
|
425
|
+
if (cronReg) return;
|
|
426
|
+
try {
|
|
427
|
+
const reg = defineCron({
|
|
428
|
+
[`${table}:gc`]: {
|
|
429
|
+
schedule: gcSchedule,
|
|
430
|
+
run: async () => {
|
|
431
|
+
// Swallow errors at the cron boundary — a stuck GC must not
|
|
432
|
+
// crash the process. The scheduler already logs thrown errors,
|
|
433
|
+
// but we also don't want a transient DB glitch to propagate.
|
|
434
|
+
try {
|
|
435
|
+
await gcNow();
|
|
436
|
+
} catch (err) {
|
|
437
|
+
console.warn(
|
|
438
|
+
`[@mandujs/core/auth/tokens] GC sweep failed: ${
|
|
439
|
+
err instanceof Error ? err.message : String(err)
|
|
440
|
+
}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
reg.start();
|
|
447
|
+
cronReg = reg;
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
450
|
+
console.warn(
|
|
451
|
+
`[@mandujs/core/auth/tokens] GC cron disabled: ${msg}. ` +
|
|
452
|
+
`Call store.gcNow() manually if needed.`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Fire-and-forget init + cron wiring — any error surfaces on the first
|
|
458
|
+
// real call. Mirrors session-sqlite.ts.
|
|
459
|
+
void ensureInit().then(startCronIfEnabled);
|
|
460
|
+
|
|
461
|
+
// ─── mint ─────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
async function mint(
|
|
464
|
+
purpose: TokenPurpose,
|
|
465
|
+
userId: string,
|
|
466
|
+
meta?: Record<string, string>,
|
|
467
|
+
): Promise<{ token: string; record: TokenRecord }> {
|
|
468
|
+
if (closed) {
|
|
469
|
+
throw new Error("[@mandujs/core/auth/tokens] store is closed.");
|
|
470
|
+
}
|
|
471
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
472
|
+
throw new TypeError(
|
|
473
|
+
"[@mandujs/core/auth/tokens] mint: userId must be a non-empty string.",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
await ensureInit();
|
|
477
|
+
|
|
478
|
+
const id = newId();
|
|
479
|
+
const nonce = generateNonce();
|
|
480
|
+
const tokenHash = hashNonce(nonce, purpose, secret);
|
|
481
|
+
const expiresAt = Date.now() + ttlByPurpose[purpose] * 1000;
|
|
482
|
+
const metaJson =
|
|
483
|
+
meta && Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
|
|
484
|
+
|
|
485
|
+
const sql = `INSERT INTO ${table} (id, user_id, purpose, token_hash, meta, expires_at, consumed_at) VALUES ($1, $2, $3, $4, $5, $6, NULL)`;
|
|
486
|
+
await execWithParams(db, sql, [id, userId, purpose, tokenHash, metaJson, expiresAt]);
|
|
487
|
+
|
|
488
|
+
const record: TokenRecord = {
|
|
489
|
+
id,
|
|
490
|
+
userId,
|
|
491
|
+
purpose,
|
|
492
|
+
tokenHash,
|
|
493
|
+
meta: meta && Object.keys(meta).length > 0 ? { ...meta } : undefined,
|
|
494
|
+
expiresAt,
|
|
495
|
+
consumedAt: null,
|
|
496
|
+
};
|
|
497
|
+
return { token: `${id}.${nonce}`, record };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── consume ──────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
async function consume(
|
|
503
|
+
purpose: TokenPurpose,
|
|
504
|
+
token: string,
|
|
505
|
+
): Promise<TokenRecord | null> {
|
|
506
|
+
if (closed) {
|
|
507
|
+
throw new Error("[@mandujs/core/auth/tokens] store is closed.");
|
|
508
|
+
}
|
|
509
|
+
// Validate BEFORE ensureInit — malformed tokens are cheap to reject
|
|
510
|
+
// without opening the DB. But we still need init for the DB path below.
|
|
511
|
+
const parsed = parseToken(token);
|
|
512
|
+
if (!parsed) return null;
|
|
513
|
+
await ensureInit();
|
|
514
|
+
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
const expectedHash = hashNonce(parsed.nonce, purpose, secret);
|
|
517
|
+
|
|
518
|
+
// Wrap in a transaction so the SELECT and the consuming UPDATE cannot be
|
|
519
|
+
// interleaved by a second consumer. Under WAL with SQLite's single-
|
|
520
|
+
// writer-serialised model, the second tx blocks on the first and then
|
|
521
|
+
// observes `consumed_at IS NOT NULL`.
|
|
522
|
+
let result: TokenRecord | null = null;
|
|
523
|
+
await db.transaction(async (tx) => {
|
|
524
|
+
const row = await queryOne<TokenRow>(
|
|
525
|
+
tx,
|
|
526
|
+
`SELECT id, user_id, purpose, token_hash, meta, expires_at, consumed_at FROM ${table} WHERE id = $1 LIMIT 1`,
|
|
527
|
+
[parsed.id],
|
|
528
|
+
);
|
|
529
|
+
if (!row) return; // unknown id
|
|
530
|
+
|
|
531
|
+
// Collapse every validation failure into "return null" without
|
|
532
|
+
// revealing which check fired. Order doesn't matter for correctness
|
|
533
|
+
// but we do the cheap checks first to keep the hot path fast.
|
|
534
|
+
if (row.purpose !== purpose) return;
|
|
535
|
+
if (Number(row.expires_at) <= now) return;
|
|
536
|
+
if (row.consumed_at !== null) return;
|
|
537
|
+
if (!safeEqual(row.token_hash, expectedHash)) return;
|
|
538
|
+
|
|
539
|
+
// Conditional UPDATE — the `consumed_at IS NULL` predicate is the
|
|
540
|
+
// atomic guard against a racing consumer. Even if two transactions
|
|
541
|
+
// both passed the SELECT (which WAL prevents at the single-writer
|
|
542
|
+
// level, but we keep the belt for correctness), only one UPDATE
|
|
543
|
+
// changes a row.
|
|
544
|
+
await execWithParams(
|
|
545
|
+
tx,
|
|
546
|
+
`UPDATE ${table} SET consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL`,
|
|
547
|
+
[now, row.id],
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// Re-read the row to confirm we won the race AND to return the
|
|
551
|
+
// authoritative state. A concurrent consumer would have flipped
|
|
552
|
+
// `consumed_at` to some other timestamp between our check and update
|
|
553
|
+
// — we cross-check by comparing back.
|
|
554
|
+
const updated = await queryOne<TokenRow>(
|
|
555
|
+
tx,
|
|
556
|
+
`SELECT id, user_id, purpose, token_hash, meta, expires_at, consumed_at FROM ${table} WHERE id = $1 LIMIT 1`,
|
|
557
|
+
[row.id],
|
|
558
|
+
);
|
|
559
|
+
if (!updated || updated.consumed_at === null) {
|
|
560
|
+
// Either vanished (impossible in a tx) or the UPDATE didn't land
|
|
561
|
+
// (should be impossible given our IS NULL guard) — fall through
|
|
562
|
+
// to null.
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// If another tx wrote a different `consumed_at`, concede and return
|
|
566
|
+
// null — we lost the race.
|
|
567
|
+
if (Number(updated.consumed_at) !== now) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
result = rowToRecord(updated);
|
|
571
|
+
});
|
|
572
|
+
return result;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ─── gcNow ────────────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
async function gcNow(): Promise<number> {
|
|
578
|
+
if (closed) {
|
|
579
|
+
throw new Error("[@mandujs/core/auth/tokens] store is closed.");
|
|
580
|
+
}
|
|
581
|
+
await ensureInit();
|
|
582
|
+
|
|
583
|
+
const now = Date.now();
|
|
584
|
+
let deleted = 0;
|
|
585
|
+
await db.transaction(async (tx) => {
|
|
586
|
+
const countSql = `SELECT COUNT(*) AS n FROM ${table} WHERE expires_at <= $1 OR consumed_at IS NOT NULL`;
|
|
587
|
+
const cnt = await queryOne<{ n: number | bigint }>(tx, countSql, [now]);
|
|
588
|
+
deleted = cnt ? Number(cnt.n) : 0;
|
|
589
|
+
const delSql = `DELETE FROM ${table} WHERE expires_at <= $1 OR consumed_at IS NOT NULL`;
|
|
590
|
+
await execWithParams(tx, delSql, [now]);
|
|
591
|
+
});
|
|
592
|
+
return deleted;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ─── close ────────────────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
async function close(): Promise<void> {
|
|
598
|
+
if (closed) return;
|
|
599
|
+
closed = true;
|
|
600
|
+
if (cronReg) {
|
|
601
|
+
try {
|
|
602
|
+
await cronReg.stop();
|
|
603
|
+
} catch {
|
|
604
|
+
// Best-effort shutdown — don't mask the caller's flow.
|
|
605
|
+
}
|
|
606
|
+
cronReg = null;
|
|
607
|
+
}
|
|
608
|
+
await db.close();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return { mint, consume, gcNow, close };
|
|
612
|
+
}
|