@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.
Files changed (414) hide show
  1. package/README.md +654 -654
  2. package/package.json +106 -210
  3. package/scripts/postinstall-lock.ts +153 -153
  4. package/src/a11y/__tests__/run-audit.test.ts +333 -333
  5. package/src/a11y/fix-hints.ts +76 -76
  6. package/src/a11y/index.ts +18 -18
  7. package/src/a11y/run-audit.ts +15 -15
  8. package/src/a11y/types.ts +125 -125
  9. package/src/agent/__tests__/apply.test.ts +224 -0
  10. package/src/agent/__tests__/context.test.ts +97 -98
  11. package/src/agent/apply.ts +1031 -0
  12. package/src/agent/context.ts +552 -552
  13. package/src/agent/index.ts +4 -3
  14. package/src/agent/plan.ts +161 -243
  15. package/src/agent/repair.ts +239 -162
  16. package/src/agent/sync.ts +197 -198
  17. package/src/agent/types.ts +242 -94
  18. package/src/agent/verify.ts +55 -55
  19. package/src/auth/__tests__/login.test.ts +1 -1
  20. package/src/auth/__tests__/password.test.ts +15 -5
  21. package/src/auth/__tests__/reset.test.ts +2 -2
  22. package/src/auth/__tests__/tokens.test.ts +274 -274
  23. package/src/auth/__tests__/verification.test.ts +274 -274
  24. package/src/auth/index.ts +76 -76
  25. package/src/auth/login.ts +225 -225
  26. package/src/auth/password.ts +14 -2
  27. package/src/auth/reset.ts +243 -243
  28. package/src/auth/tokens.ts +612 -612
  29. package/src/auth/verification.ts +253 -253
  30. package/src/brain/__tests__/redactor.test.ts +94 -94
  31. package/src/brain/adapters/__tests__/_helpers.ts +83 -83
  32. package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
  33. package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
  34. package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
  35. package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
  36. package/src/brain/adapters/anthropic-oauth.ts +1 -1
  37. package/src/brain/adapters/chatgpt-auth.ts +300 -300
  38. package/src/brain/adapters/index.ts +319 -319
  39. package/src/brain/adapters/oauth-flow.ts +439 -439
  40. package/src/brain/consent.ts +240 -240
  41. package/src/brain/credentials.ts +396 -396
  42. package/src/brain/doctor/analyzer.ts +7 -7
  43. package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
  44. package/src/bundler/__tests__/build-runner.ts +166 -166
  45. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
  46. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  47. package/src/bundler/__tests__/css.test.ts +49 -20
  48. package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
  49. package/src/bundler/__tests__/extended-watch.test.ts +711 -711
  50. package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
  51. package/src/bundler/__tests__/generation.test.ts +447 -0
  52. package/src/bundler/__tests__/hdr.test.ts +24 -18
  53. package/src/bundler/__tests__/hmr-client.test.ts +62 -24
  54. package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
  55. package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
  56. package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
  57. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
  58. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
  59. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
  60. package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
  61. package/src/bundler/analyzer.ts +15 -15
  62. package/src/bundler/budget.ts +404 -404
  63. package/src/bundler/build.test.ts +898 -915
  64. package/src/bundler/build.ts +113 -24
  65. package/src/bundler/client-boundary-transform.ts +977 -977
  66. package/src/bundler/css.ts +65 -44
  67. package/src/bundler/dev.ts +105 -73
  68. package/src/bundler/fast-refresh-preamble.ts +47 -47
  69. package/src/bundler/generation.ts +602 -0
  70. package/src/bundler/index.ts +3 -3
  71. package/src/bundler/manifest-schema.ts +55 -40
  72. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  73. package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
  74. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
  75. package/src/bundler/plugins/block-generated-imports.ts +16 -15
  76. package/src/bundler/plugins/index.ts +83 -83
  77. package/src/bundler/plugins/react-compiler-config.ts +108 -108
  78. package/src/bundler/plugins/react-compiler-lint.ts +253 -253
  79. package/src/bundler/plugins/react-compiler.ts +162 -162
  80. package/src/bundler/prerender.ts +29 -9
  81. package/src/bundler/reverse-import-graph.ts +339 -339
  82. package/src/bundler/safe-build.test.ts +1 -1
  83. package/src/bundler/safe-build.ts +103 -103
  84. package/src/bundler/scenario-matrix.ts +229 -229
  85. package/src/bundler/types.ts +58 -56
  86. package/src/bundler/vendor-cache-types.ts +130 -130
  87. package/src/bundler/vendor-cache.ts +526 -526
  88. package/src/change/snapshot.ts +18 -18
  89. package/src/client/Form.tsx +105 -105
  90. package/src/client/Link.tsx +9 -9
  91. package/src/client/__tests__/props-serialization.test.ts +37 -37
  92. package/src/client/__tests__/use-sse.test.ts +153 -153
  93. package/src/client/globals.ts +1 -1
  94. package/src/client/hooks.ts +362 -362
  95. package/src/client/hydrate.ts +2 -2
  96. package/src/client/index.ts +1 -1
  97. package/src/client/island.ts +79 -79
  98. package/src/client/prefetch-helper.ts +55 -55
  99. package/src/client/props-serialization.ts +233 -233
  100. package/src/client/runtime-entry.ts +598 -598
  101. package/src/client/runtime.ts +1 -1
  102. package/src/client/serialize.ts +50 -50
  103. package/src/client/use-fetch.ts +6 -6
  104. package/src/client/use-head.ts +197 -197
  105. package/src/client/use-sse.ts +378 -378
  106. package/src/client/window-state.ts +101 -101
  107. package/src/components/Image-compat.ts +3 -0
  108. package/src/components/Image.tsx +162 -162
  109. package/src/config/validate.ts +1 -1
  110. package/src/config/watcher.ts +311 -311
  111. package/src/constants.ts +40 -40
  112. package/src/content/collection.ts +9 -9
  113. package/src/content/content-layer.ts +7 -7
  114. package/src/content/data-store.ts +245 -245
  115. package/src/content/frontmatter.ts +189 -189
  116. package/src/content/generate-types.ts +1 -1
  117. package/src/content/index.ts +2 -2
  118. package/src/content/loader-context.ts +171 -171
  119. package/src/content/loaders/api.ts +216 -216
  120. package/src/content/loaders/file.ts +172 -172
  121. package/src/content/loaders/glob.ts +253 -253
  122. package/src/content/loaders/index.ts +34 -34
  123. package/src/content/loaders/types.ts +137 -137
  124. package/src/content/meta-store.ts +209 -209
  125. package/src/content/prebuild.test.ts +571 -571
  126. package/src/content/prebuild.ts +636 -636
  127. package/src/content/schema.ts +20 -20
  128. package/src/content/sidebar.ts +630 -630
  129. package/src/content/slug.ts +110 -110
  130. package/src/content/types.ts +282 -282
  131. package/src/content/watcher.ts +135 -135
  132. package/src/contract/client-safe.test.ts +42 -42
  133. package/src/contract/client-safe.ts +114 -114
  134. package/src/contract/define.ts +11 -11
  135. package/src/contract/index.ts +1 -1
  136. package/src/contract/normalize.test.ts +276 -276
  137. package/src/contract/normalize.ts +410 -410
  138. package/src/contract/registry.test.ts +206 -206
  139. package/src/contract/route-helpers.ts +1 -1
  140. package/src/contract/rpc.ts +443 -443
  141. package/src/contract/types.ts +58 -58
  142. package/src/db/__tests__/db.test.ts +9 -9
  143. package/src/db/index.ts +2 -2
  144. package/src/db/migrations/history-table.ts +345 -345
  145. package/src/db/migrations/index.ts +3 -3
  146. package/src/db/migrations/lock.ts +324 -324
  147. package/src/db/migrations/runner.ts +650 -650
  148. package/src/deploy/cache.ts +140 -140
  149. package/src/deploy/compile/vercel.ts +344 -344
  150. package/src/deploy/index.ts +87 -87
  151. package/src/deploy/inference/brain.ts +268 -268
  152. package/src/deploy/inference/context.ts +82 -82
  153. package/src/deploy/inference/filling-extract.ts +245 -245
  154. package/src/deploy/inference/heuristic.ts +182 -182
  155. package/src/deploy/intent.ts +173 -173
  156. package/src/deploy/plan.ts +178 -178
  157. package/src/design/__tests__/agents-link.test.ts +109 -109
  158. package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
  159. package/src/design/__tests__/lint.test.ts +110 -110
  160. package/src/design/__tests__/parser.test.ts +195 -195
  161. package/src/design/__tests__/tailwind-theme.test.ts +229 -229
  162. package/src/design/agents-link.ts +165 -165
  163. package/src/design/diff.ts +138 -138
  164. package/src/design/extract.ts +285 -285
  165. package/src/design/index.ts +102 -102
  166. package/src/design/lint.ts +209 -209
  167. package/src/design/parser.ts +555 -555
  168. package/src/design/patch.ts +241 -241
  169. package/src/design/scaffold.ts +147 -147
  170. package/src/design/tailwind-theme.ts +441 -441
  171. package/src/design/types.ts +210 -210
  172. package/src/desktop/__tests__/smoke.test.ts +2 -2
  173. package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
  174. package/src/desktop/__tests__/window.test.ts +20 -99
  175. package/src/desktop/__tests__/worker.test.ts +266 -266
  176. package/src/desktop/index.ts +43 -43
  177. package/src/desktop/types.ts +158 -158
  178. package/src/desktop/webview-fallback.ts +17 -4
  179. package/src/desktop/window.ts +17 -8
  180. package/src/desktop/worker.ts +180 -180
  181. package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
  182. package/src/dev-error-overlay/index.ts +30 -30
  183. package/src/dev-error-overlay/overlay-injector.ts +243 -243
  184. package/src/dev-error-overlay/overlay-styles.ts +52 -52
  185. package/src/dev-error-overlay/types.ts +66 -66
  186. package/src/devtools/ai/context-builder.ts +375 -375
  187. package/src/devtools/ai/index.ts +25 -25
  188. package/src/devtools/ai/mcp-connector.ts +25 -25
  189. package/src/devtools/client/catchers/error-catcher.ts +344 -344
  190. package/src/devtools/client/catchers/index.ts +18 -18
  191. package/src/devtools/client/components/index.ts +39 -39
  192. package/src/devtools/client/components/mandu-character.tsx +331 -331
  193. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  194. package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
  195. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  196. package/src/devtools/client/components/panel/network-panel.tsx +291 -291
  197. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  198. package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
  199. package/src/devtools/client/filters/context-filters.ts +282 -282
  200. package/src/devtools/client/filters/index.ts +16 -16
  201. package/src/devtools/client/index.ts +63 -63
  202. package/src/devtools/hook/create-hook.ts +207 -207
  203. package/src/devtools/hook/index.ts +13 -13
  204. package/src/devtools/index.ts +439 -439
  205. package/src/devtools/init.ts +265 -265
  206. package/src/devtools/protocol.ts +237 -237
  207. package/src/devtools/server/index.ts +17 -17
  208. package/src/devtools/types.ts +35 -35
  209. package/src/devtools/worker/index.ts +25 -25
  210. package/src/devtools/worker/redaction-worker.ts +233 -233
  211. package/src/diagnose/__tests__/checks.test.ts +136 -136
  212. package/src/diagnose/checks.ts +185 -185
  213. package/src/diagnose/index.ts +17 -17
  214. package/src/diagnose/run.ts +10 -10
  215. package/src/diagnose/types.ts +53 -53
  216. package/src/email/__tests__/email.test.ts +355 -355
  217. package/src/email/index.ts +282 -282
  218. package/src/email/smtp.ts +64 -64
  219. package/src/error/domains.ts +265 -265
  220. package/src/error/types.ts +6 -6
  221. package/src/errors/extractor.ts +409 -409
  222. package/src/errors/index.ts +19 -19
  223. package/src/filling/__tests__/session-sqlite.test.ts +5 -1
  224. package/src/filling/auth.ts +308 -308
  225. package/src/filling/body-parse.test.ts +60 -60
  226. package/src/filling/cookie-codec.ts +5 -3
  227. package/src/filling/deps.ts +265 -265
  228. package/src/filling/head-method.test.ts +154 -154
  229. package/src/filling/session-sqlite.ts +617 -617
  230. package/src/filling/sse.ts +5 -5
  231. package/src/filling/ws.ts +78 -78
  232. package/src/generator/generate.ts +30 -30
  233. package/src/generator/index.ts +3 -3
  234. package/src/generator/templates.test.ts +48 -48
  235. package/src/generator/templates.ts +219 -219
  236. package/src/guard/__tests__/design-inline-class.test.ts +219 -219
  237. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
  238. package/src/guard/analyzer.ts +360 -360
  239. package/src/guard/auto-correct.ts +1 -1
  240. package/src/guard/check.ts +39 -24
  241. package/src/guard/config-guard.ts +13 -13
  242. package/src/guard/contract-guard.ts +9 -9
  243. package/src/guard/define-rule.ts +243 -243
  244. package/src/guard/design-inline-class.ts +393 -393
  245. package/src/guard/file-type.test.ts +24 -24
  246. package/src/guard/fs-routes-policy.ts +51 -51
  247. package/src/guard/graph.ts +1 -1
  248. package/src/guard/healing.ts +36 -36
  249. package/src/guard/index.ts +11 -11
  250. package/src/guard/presets/atomic.ts +70 -70
  251. package/src/guard/presets/clean.ts +77 -77
  252. package/src/guard/presets/fsd.ts +79 -79
  253. package/src/guard/presets/hexagonal.ts +68 -68
  254. package/src/guard/reporter.ts +442 -442
  255. package/src/guard/rule-presets.ts +379 -379
  256. package/src/guard/semantic-slots.ts +1 -1
  257. package/src/guard/suggestions.ts +358 -358
  258. package/src/guard/tsgolint-bridge.ts +512 -512
  259. package/src/guard/types.ts +348 -348
  260. package/src/guard/watcher.ts +405 -405
  261. package/src/i18n/define.ts +126 -126
  262. package/src/i18n/index.ts +52 -52
  263. package/src/i18n/message-registry.ts +173 -173
  264. package/src/i18n/types.ts +112 -112
  265. package/src/id/__tests__/id.test.ts +3 -3
  266. package/src/id/index.ts +105 -105
  267. package/src/index.ts +9 -48
  268. package/src/internal/client-boundary.ts +266 -266
  269. package/src/internal/index.ts +2 -2
  270. package/src/kitchen/api/agent-devtools-api.ts +779 -779
  271. package/src/kitchen/api/bundle-inspector.ts +179 -0
  272. package/src/kitchen/api/errors-grouping.ts +126 -126
  273. package/src/kitchen/api/file-api.ts +11 -11
  274. package/src/kitchen/kitchen-handler.ts +36 -0
  275. package/src/kitchen/kitchen-ui.ts +456 -0
  276. package/src/logging/index.ts +22 -22
  277. package/src/logging/transports.ts +365 -365
  278. package/src/middleware/bridge.ts +147 -147
  279. package/src/middleware/compose.ts +134 -134
  280. package/src/middleware/compress.ts +62 -62
  281. package/src/middleware/cors.ts +47 -47
  282. package/src/middleware/csrf.ts +328 -328
  283. package/src/middleware/define.ts +132 -132
  284. package/src/middleware/jwt.ts +134 -134
  285. package/src/middleware/logger.ts +58 -58
  286. package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
  287. package/src/middleware/oauth/index.ts +505 -505
  288. package/src/middleware/oauth/providers.ts +115 -115
  289. package/src/middleware/rate-limit/index.ts +522 -522
  290. package/src/middleware/rate-limit/sqlite-store.ts +382 -382
  291. package/src/middleware/scheduler-cron.ts +96 -96
  292. package/src/middleware/secure/__tests__/secure.test.ts +360 -360
  293. package/src/middleware/secure/csp.ts +193 -193
  294. package/src/middleware/session.ts +174 -174
  295. package/src/middleware/timeout.ts +55 -55
  296. package/src/observability/logger-adapter.ts +36 -36
  297. package/src/observability/sqlite-store.ts +254 -254
  298. package/src/openapi/generator.ts +1 -1
  299. package/src/openapi/openapi.test.ts +43 -43
  300. package/src/perf/__tests__/user-marks.test.ts +354 -354
  301. package/src/perf/index.ts +133 -133
  302. package/src/perf/user-marks.ts +1 -1
  303. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
  304. package/src/plugins/__tests__/runner.test.ts +409 -409
  305. package/src/plugins/define.ts +124 -124
  306. package/src/plugins/examples/dep-check-plugin.ts +80 -80
  307. package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
  308. package/src/plugins/examples/sitemap-plugin.ts +65 -65
  309. package/src/plugins/runner.ts +361 -361
  310. package/src/plugins/types.ts +368 -368
  311. package/src/report/index.ts +1 -1
  312. package/src/resource/__tests__/generator.test.ts +7 -7
  313. package/src/resource/__tests__/schema.test.ts +14 -14
  314. package/src/resource/ddl/__tests__/diff.test.ts +639 -639
  315. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  316. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
  317. package/src/resource/ddl/emit.ts +146 -146
  318. package/src/resource/ddl/persistence-types.ts +218 -218
  319. package/src/resource/ddl/type-map.ts +223 -223
  320. package/src/resource/ddl/types.ts +232 -232
  321. package/src/resource/generator-repo.ts +630 -630
  322. package/src/resource/generator-schema.ts +11 -11
  323. package/src/resource/generators/slot.ts +72 -72
  324. package/src/resource/schema.ts +21 -21
  325. package/src/router/client-entry.test.ts +227 -227
  326. package/src/router/client-entry.ts +218 -218
  327. package/src/router/fs-patterns.test.ts +96 -96
  328. package/src/router/fs-routes.test.ts +532 -532
  329. package/src/router/fs-routes.ts +53 -53
  330. package/src/router/fs-scanner.ts +216 -216
  331. package/src/router/fs-types.ts +19 -19
  332. package/src/router/route-source-analyzer.ts +521 -521
  333. package/src/routes/index.ts +74 -74
  334. package/src/routes/metadata-routes.ts +427 -427
  335. package/src/routes/types.ts +341 -341
  336. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  337. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
  338. package/src/runtime/__tests__/hdr-client.test.ts +223 -223
  339. package/src/runtime/__tests__/http-errors.test.ts +117 -117
  340. package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
  341. package/src/runtime/__tests__/not-found.test.ts +152 -152
  342. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  343. package/src/runtime/__tests__/page-render-response.test.ts +517 -517
  344. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  345. package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
  346. package/src/runtime/adapter.ts +47 -47
  347. package/src/runtime/boundary.tsx +252 -252
  348. package/src/runtime/cache.ts +494 -494
  349. package/src/runtime/compose.ts +222 -222
  350. package/src/runtime/devtools-adapter.ts +68 -68
  351. package/src/runtime/escape.ts +34 -34
  352. package/src/runtime/fast-refresh-runtime.ts +322 -322
  353. package/src/runtime/fast-refresh-types.ts +1 -1
  354. package/src/runtime/handler.ts +65 -65
  355. package/src/runtime/handlers.ts +30 -11
  356. package/src/runtime/http-errors.ts +113 -113
  357. package/src/runtime/image-handler.ts +1 -1
  358. package/src/runtime/lifecycle.ts +381 -381
  359. package/src/runtime/logger.test.ts +345 -345
  360. package/src/runtime/middleware.ts +264 -264
  361. package/src/runtime/not-found.ts +93 -93
  362. package/src/runtime/observability-lifecycle.ts +290 -290
  363. package/src/runtime/openapi-endpoint.ts +236 -236
  364. package/src/runtime/page-render-response.ts +287 -274
  365. package/src/runtime/ppr.ts +74 -74
  366. package/src/runtime/rate-limit.ts +1 -1
  367. package/src/runtime/redirect.ts +1 -1
  368. package/src/runtime/registry.ts +171 -171
  369. package/src/runtime/request-middleware.ts +31 -31
  370. package/src/runtime/router.test.ts +4 -4
  371. package/src/runtime/router.ts +10 -10
  372. package/src/runtime/server.ts +39 -20
  373. package/src/runtime/shims.ts +48 -48
  374. package/src/runtime/ssr.ts +43 -17
  375. package/src/runtime/static-files.ts +369 -289
  376. package/src/runtime/streaming-ssr.ts +219 -180
  377. package/src/runtime/trace.ts +144 -144
  378. package/src/scheduler/__tests__/scheduler.test.ts +12 -11
  379. package/src/scheduler/index.ts +11 -5
  380. package/src/scheduler/validate.ts +169 -169
  381. package/src/seo/index.ts +219 -219
  382. package/src/seo/integration/ssr.ts +306 -306
  383. package/src/seo/render/basic.ts +435 -435
  384. package/src/seo/render/index.ts +143 -143
  385. package/src/seo/render/jsonld.ts +539 -539
  386. package/src/seo/render/opengraph.ts +197 -197
  387. package/src/seo/render/robots.ts +116 -116
  388. package/src/seo/render/sitemap.ts +137 -137
  389. package/src/seo/render/twitter.ts +127 -127
  390. package/src/seo/resolve/opengraph.ts +143 -143
  391. package/src/seo/resolve/robots.ts +73 -73
  392. package/src/seo/resolve/title.ts +94 -94
  393. package/src/seo/resolve/twitter.ts +73 -73
  394. package/src/seo/resolve/url.ts +104 -104
  395. package/src/seo/routes/index.ts +290 -290
  396. package/src/seo/types.ts +588 -588
  397. package/src/slot/validator.ts +39 -39
  398. package/src/spec/schema.ts +35 -35
  399. package/src/storage/s3/__tests__/s3.test.ts +479 -479
  400. package/src/storage/s3/index.ts +412 -412
  401. package/src/testing/__tests__/assertions.test.ts +632 -632
  402. package/src/testing/__tests__/reporter.test.ts +454 -454
  403. package/src/testing/assertions.ts +986 -986
  404. package/src/testing/db.ts +157 -157
  405. package/src/testing/index.ts +9 -3
  406. package/src/testing/lcov.ts +192 -0
  407. package/src/testing/mocks.ts +203 -203
  408. package/src/testing/session.ts +190 -190
  409. package/src/types/branded.ts +56 -56
  410. package/src/types/index.ts +1 -1
  411. package/src/utils/safe-io.ts +188 -188
  412. package/src/utils/string-safe.ts +298 -298
  413. package/src/watcher/__tests__/watcher.test.ts +59 -59
  414. package/src/watcher/watcher.ts +61 -61
@@ -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
+ }