@edge-base/server 0.1.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 (309) hide show
  1. package/admin-build/.gitkeep +0 -0
  2. package/admin-build/_app/env.js +1 -0
  3. package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
  4. package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
  5. package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
  6. package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
  7. package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
  8. package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
  9. package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
  10. package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
  11. package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
  12. package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
  13. package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
  14. package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
  15. package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
  16. package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
  17. package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
  18. package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
  19. package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
  20. package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
  21. package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
  22. package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
  23. package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
  24. package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
  25. package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
  26. package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
  27. package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
  28. package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
  29. package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
  30. package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
  31. package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
  32. package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
  33. package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
  34. package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
  35. package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
  36. package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
  37. package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
  38. package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
  39. package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
  40. package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
  41. package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
  42. package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
  43. package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
  44. package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
  45. package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
  46. package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
  47. package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
  48. package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
  49. package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
  50. package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
  51. package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
  52. package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
  53. package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
  54. package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
  55. package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
  56. package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
  57. package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
  58. package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
  59. package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
  60. package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
  61. package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
  62. package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
  63. package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
  64. package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
  65. package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
  66. package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
  67. package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
  68. package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
  69. package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
  70. package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
  71. package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
  72. package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
  73. package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
  74. package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
  75. package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
  76. package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
  77. package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
  78. package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
  79. package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
  80. package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
  81. package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
  82. package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
  83. package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
  84. package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
  85. package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
  86. package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
  87. package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
  88. package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
  89. package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
  90. package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
  91. package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
  92. package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
  93. package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
  94. package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
  95. package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
  96. package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
  97. package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
  98. package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
  99. package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
  100. package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
  101. package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
  102. package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
  103. package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
  104. package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
  105. package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
  106. package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
  107. package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
  108. package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
  109. package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
  110. package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
  111. package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
  112. package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
  113. package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
  114. package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
  115. package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
  116. package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
  117. package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
  118. package/admin-build/_app/version.json +1 -0
  119. package/admin-build/favicon.svg +26 -0
  120. package/admin-build/index.html +45 -0
  121. package/openapi.json +19543 -0
  122. package/package.json +66 -0
  123. package/src/__tests__/admin-assets.test.ts +55 -0
  124. package/src/__tests__/admin-data-routes.test.ts +488 -0
  125. package/src/__tests__/admin-db-target.test.ts +103 -0
  126. package/src/__tests__/admin-routing.test.ts +31 -0
  127. package/src/__tests__/admin-user-management.test.ts +311 -0
  128. package/src/__tests__/analytics-query.test.ts +75 -0
  129. package/src/__tests__/auth-d1.test.ts +749 -0
  130. package/src/__tests__/auth-db-adapter.test.ts +73 -0
  131. package/src/__tests__/auth-jwt.test.ts +440 -0
  132. package/src/__tests__/auth-oauth.test.ts +389 -0
  133. package/src/__tests__/auth-password.test.ts +367 -0
  134. package/src/__tests__/auth-redirect.test.ts +87 -0
  135. package/src/__tests__/backup-restore.test.ts +711 -0
  136. package/src/__tests__/broadcast.test.ts +128 -0
  137. package/src/__tests__/cli.test.ts +178 -0
  138. package/src/__tests__/cloudflare-realtime.test.ts +113 -0
  139. package/src/__tests__/config.test.ts +469 -0
  140. package/src/__tests__/cors.test.ts +154 -0
  141. package/src/__tests__/cron.test.ts +302 -0
  142. package/src/__tests__/d1-handler.test.ts +402 -0
  143. package/src/__tests__/d1-sql.test.ts +120 -0
  144. package/src/__tests__/database-live-config.test.ts +42 -0
  145. package/src/__tests__/database-live-emitter.test.ts +56 -0
  146. package/src/__tests__/database-live-filters.test.ts +63 -0
  147. package/src/__tests__/database-live-route.test.ts +113 -0
  148. package/src/__tests__/db-sql.test.ts +163 -0
  149. package/src/__tests__/do-lifecycle.test.ts +263 -0
  150. package/src/__tests__/do-router.test.ts +729 -0
  151. package/src/__tests__/email-provider.test.ts +128 -0
  152. package/src/__tests__/email-templates.test.ts +528 -0
  153. package/src/__tests__/error-format.test.ts +250 -0
  154. package/src/__tests__/field-ops.test.ts +242 -0
  155. package/src/__tests__/functions-context.test.ts +334 -0
  156. package/src/__tests__/functions-d1-proxy.test.ts +229 -0
  157. package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
  158. package/src/__tests__/functions-route.test.ts +139 -0
  159. package/src/__tests__/internal-request.test.ts +77 -0
  160. package/src/__tests__/log-writer.test.ts +44 -0
  161. package/src/__tests__/logger.test.ts +58 -0
  162. package/src/__tests__/meta-admin-proxy.test.ts +48 -0
  163. package/src/__tests__/meta-export-coverage.test.ts +191 -0
  164. package/src/__tests__/meta-route-registration.test.ts +47 -0
  165. package/src/__tests__/namespace-dump.test.ts +28 -0
  166. package/src/__tests__/oauth-providers.test.ts +337 -0
  167. package/src/__tests__/openapi-coverage.test.ts +144 -0
  168. package/src/__tests__/pagination.test.ts +59 -0
  169. package/src/__tests__/password-policy.test.ts +191 -0
  170. package/src/__tests__/plugin-migrations.test.ts +379 -0
  171. package/src/__tests__/postgres-batch-compat.test.ts +133 -0
  172. package/src/__tests__/postgres-dialect.test.ts +328 -0
  173. package/src/__tests__/postgres-executor.test.ts +79 -0
  174. package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
  175. package/src/__tests__/postgres-schema-init.test.ts +105 -0
  176. package/src/__tests__/postgres-table-utils.test.ts +107 -0
  177. package/src/__tests__/presence.test.ts +199 -0
  178. package/src/__tests__/provider.test.ts +550 -0
  179. package/src/__tests__/public-user-profile.test.ts +339 -0
  180. package/src/__tests__/push-handlers.test.ts +179 -0
  181. package/src/__tests__/push-provider.test.ts +80 -0
  182. package/src/__tests__/push-token.test.ts +418 -0
  183. package/src/__tests__/query.test.ts +771 -0
  184. package/src/__tests__/rate-limit.test.ts +260 -0
  185. package/src/__tests__/room-access-policy.test.ts +101 -0
  186. package/src/__tests__/room-handler-context.test.ts +130 -0
  187. package/src/__tests__/room-monitoring.test.ts +138 -0
  188. package/src/__tests__/room-runtime-routing.test.ts +222 -0
  189. package/src/__tests__/room.test.ts +254 -0
  190. package/src/__tests__/route-parser.test.ts +490 -0
  191. package/src/__tests__/rules.test.ts +234 -0
  192. package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
  193. package/src/__tests__/scheduled.test.ts +80 -0
  194. package/src/__tests__/schema.test.ts +1273 -0
  195. package/src/__tests__/security-hardening.test.ts +312 -0
  196. package/src/__tests__/server.unit.test.ts +333 -0
  197. package/src/__tests__/service-key-db-proxy.test.ts +650 -0
  198. package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
  199. package/src/__tests__/service-key.test.ts +757 -0
  200. package/src/__tests__/smoke-skip-report.test.ts +72 -0
  201. package/src/__tests__/sms-provider.test.ts +39 -0
  202. package/src/__tests__/sql-route.test.ts +218 -0
  203. package/src/__tests__/storage-hook-context.test.ts +115 -0
  204. package/src/__tests__/totp.test.ts +200 -0
  205. package/src/__tests__/uuid.test.ts +144 -0
  206. package/src/__tests__/validation.test.ts +773 -0
  207. package/src/__tests__/websocket-pending.test.ts +163 -0
  208. package/src/_functions-registry.ts +51 -0
  209. package/src/bench-entry.ts +9 -0
  210. package/src/cloudflare-test.d.ts +1 -0
  211. package/src/durable-objects/auth-do.ts +49 -0
  212. package/src/durable-objects/database-do.ts +2240 -0
  213. package/src/durable-objects/database-live-do.ts +949 -0
  214. package/src/durable-objects/logs-do.ts +1200 -0
  215. package/src/durable-objects/room-runtime-base.ts +1604 -0
  216. package/src/durable-objects/rooms-do.ts +2191 -0
  217. package/src/generated-config.ts +6 -0
  218. package/src/index.ts +382 -0
  219. package/src/lib/admin-assets.ts +54 -0
  220. package/src/lib/admin-db-target.ts +301 -0
  221. package/src/lib/admin-routing.ts +35 -0
  222. package/src/lib/admin-user-management.ts +464 -0
  223. package/src/lib/analytics-adapter.ts +103 -0
  224. package/src/lib/analytics-query.ts +579 -0
  225. package/src/lib/auth-d1-service.ts +1193 -0
  226. package/src/lib/auth-d1.ts +1056 -0
  227. package/src/lib/auth-db-adapter.ts +289 -0
  228. package/src/lib/auth-redirect.ts +116 -0
  229. package/src/lib/cidr.ts +115 -0
  230. package/src/lib/client-ip.ts +51 -0
  231. package/src/lib/cloudflare-realtime.ts +251 -0
  232. package/src/lib/control-db.ts +36 -0
  233. package/src/lib/cron.ts +163 -0
  234. package/src/lib/d1-handler.ts +1425 -0
  235. package/src/lib/d1-schema-init.ts +255 -0
  236. package/src/lib/d1-sql.ts +33 -0
  237. package/src/lib/database-live-config.ts +24 -0
  238. package/src/lib/database-live-emitter.ts +111 -0
  239. package/src/lib/db-sql.ts +66 -0
  240. package/src/lib/do-retry.ts +36 -0
  241. package/src/lib/do-router.ts +270 -0
  242. package/src/lib/do-sql.ts +73 -0
  243. package/src/lib/email-provider.ts +379 -0
  244. package/src/lib/email-templates.ts +285 -0
  245. package/src/lib/email-translations.ts +422 -0
  246. package/src/lib/errors.ts +151 -0
  247. package/src/lib/functions.ts +2091 -0
  248. package/src/lib/hono.ts +56 -0
  249. package/src/lib/internal-request.ts +56 -0
  250. package/src/lib/jwt.ts +354 -0
  251. package/src/lib/log-writer.ts +272 -0
  252. package/src/lib/namespace-dump.ts +125 -0
  253. package/src/lib/oauth-providers.ts +1225 -0
  254. package/src/lib/op-parser.ts +99 -0
  255. package/src/lib/openapi.ts +146 -0
  256. package/src/lib/pagination.ts +19 -0
  257. package/src/lib/password-policy.ts +102 -0
  258. package/src/lib/password.ts +145 -0
  259. package/src/lib/plugin-migrations.ts +612 -0
  260. package/src/lib/postgres-executor.ts +203 -0
  261. package/src/lib/postgres-handler.ts +1102 -0
  262. package/src/lib/postgres-schema-init.ts +341 -0
  263. package/src/lib/postgres-table-utils.ts +87 -0
  264. package/src/lib/public-user-profile.ts +187 -0
  265. package/src/lib/push-provider.ts +409 -0
  266. package/src/lib/push-token.ts +294 -0
  267. package/src/lib/query-engine.ts +768 -0
  268. package/src/lib/room-monitoring.ts +97 -0
  269. package/src/lib/room-runtime.ts +14 -0
  270. package/src/lib/route-parser.ts +434 -0
  271. package/src/lib/schema.ts +538 -0
  272. package/src/lib/schemas.ts +152 -0
  273. package/src/lib/service-key.ts +419 -0
  274. package/src/lib/sms-provider.ts +230 -0
  275. package/src/lib/startup-config.ts +99 -0
  276. package/src/lib/totp.ts +242 -0
  277. package/src/lib/uuid.ts +87 -0
  278. package/src/lib/validation.ts +205 -0
  279. package/src/lib/version.ts +2 -0
  280. package/src/lib/websocket-pending.ts +40 -0
  281. package/src/middleware/auth.ts +169 -0
  282. package/src/middleware/captcha-verify.ts +217 -0
  283. package/src/middleware/cors.ts +159 -0
  284. package/src/middleware/error-handler.ts +54 -0
  285. package/src/middleware/internal-guard.ts +26 -0
  286. package/src/middleware/logger.ts +126 -0
  287. package/src/middleware/rate-limit.ts +283 -0
  288. package/src/middleware/rules.ts +475 -0
  289. package/src/routes/admin-auth.ts +447 -0
  290. package/src/routes/admin.ts +3501 -0
  291. package/src/routes/analytics-api.ts +290 -0
  292. package/src/routes/auth.ts +4222 -0
  293. package/src/routes/backup.ts +1466 -0
  294. package/src/routes/config.ts +53 -0
  295. package/src/routes/d1.ts +109 -0
  296. package/src/routes/database-live.ts +281 -0
  297. package/src/routes/functions.ts +155 -0
  298. package/src/routes/health.ts +32 -0
  299. package/src/routes/kv.ts +167 -0
  300. package/src/routes/oauth.ts +1055 -0
  301. package/src/routes/push.ts +1465 -0
  302. package/src/routes/room.ts +639 -0
  303. package/src/routes/schema-endpoint.ts +76 -0
  304. package/src/routes/sql.ts +176 -0
  305. package/src/routes/storage.ts +1674 -0
  306. package/src/routes/tables.ts +699 -0
  307. package/src/routes/users.ts +21 -0
  308. package/src/routes/vectorize.ts +372 -0
  309. package/src/types.ts +99 -0
@@ -0,0 +1,4222 @@
1
+ /**
2
+ * Auth routes — D1-first (Phase 3: Auth DO eliminated)
3
+ *
4
+ * All auth operations go directly to D1 via auth-d1-service.
5
+ * No more DO shard routing — single D1 database for all users.
6
+ */
7
+ import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
8
+ import type { Env } from '../types.js';
9
+ import { EdgeBaseError, getAuthAccess, getAuthHandlers } from '@edge-base/shared';
10
+ import type {
11
+ AuthAccess,
12
+ AuthTrigger, EmailConfig, EmailTemplateOverrides, EmailSubjectOverrides,
13
+ EmailOtpConfig, MagicLinkConfig, MailType, MailHookCtx, MfaConfig,
14
+ PasskeysConfig, PasswordPolicyConfig, SmsConfig, SmsHookCtx, SmsType,
15
+ } from '@edge-base/shared';
16
+ import type { AuthContext } from '../middleware/auth.js';
17
+ import {
18
+ validateKey,
19
+ buildConstraintCtx,
20
+ resolveRootServiceKey,
21
+ resolveServiceKeyCandidate,
22
+ timingSafeEqual,
23
+ } from '../lib/service-key.js';
24
+ import { counter, getLimit } from '../middleware/rate-limit.js';
25
+ import { parseConfig } from '../lib/do-router.js';
26
+ import {
27
+ buildEmailActionUrl,
28
+ parseClientRedirectInput,
29
+ } from '../lib/auth-redirect.js';
30
+ import {
31
+ signAccessToken, signRefreshToken, verifyRefreshTokenWithFallback,
32
+ parseDuration, decodeTokenUnsafe, TokenExpiredError,
33
+ } from '../lib/jwt.js';
34
+ import { generateId } from '../lib/uuid.js';
35
+ import { captchaMiddleware } from '../middleware/captcha-verify.js';
36
+ import { hashPassword, verifyPassword, needsRehash } from '../lib/password.js';
37
+ import { validatePassword } from '../lib/password-policy.js';
38
+ import { createEmailProvider } from '../lib/email-provider.js';
39
+ import type { EmailProvider } from '../lib/email-provider.js';
40
+ import { createSmsProvider } from '../lib/sms-provider.js';
41
+ import { getTrustedClientIp } from '../lib/client-ip.js';
42
+ import type { SmsProvider } from '../lib/sms-provider.js';
43
+ import { renderVerifyEmail, renderPasswordReset, renderMagicLink, renderEmailOtp, renderEmailChange } from '../lib/email-templates.js';
44
+ import { getDefaultSubject } from '../lib/email-translations.js';
45
+ import {
46
+ generateTOTPSecret, generateTOTPUri, verifyTOTP,
47
+ generateRecoveryCodes, encryptSecret, decryptSecret,
48
+ } from '../lib/totp.js';
49
+ import {
50
+ getFunctionsByTrigger,
51
+ buildFunctionKvProxy,
52
+ buildFunctionD1Proxy,
53
+ buildFunctionVectorizeProxy,
54
+ buildFunctionPushProxy,
55
+ buildAdminAuthContext,
56
+ buildAdminDbProxy,
57
+ getWorkerUrl,
58
+ } from '../lib/functions.js';
59
+ import * as authService from '../lib/auth-d1-service.js';
60
+ import {
61
+ ensureAuthSchema,
62
+ lookupEmail,
63
+ registerEmailPending,
64
+ confirmEmail,
65
+ deleteEmail,
66
+ deleteEmailPending,
67
+ registerAnonPending,
68
+ confirmAnon,
69
+ deleteAnon,
70
+ deleteOAuth,
71
+ lookupPhone,
72
+ registerPhonePending,
73
+ confirmPhone,
74
+ registerPasskey,
75
+ deletePasskey,
76
+ } from '../lib/auth-d1.js';
77
+ import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
78
+ import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
79
+ import { queuePublicUserProjectionSync, syncPublicUserProjection } from '../lib/public-user-profile.js';
80
+
81
+
82
+ /** Resolve AuthDb from Hono context or raw env. Defaults to D1 (AUTH_DB binding). */
83
+ function getAuthDb(c: { env: Env }): AuthDb {
84
+ return resolveAuthDb(c.env as unknown as Record<string, unknown>);
85
+ }
86
+
87
+ /** Resolve AuthDb from raw env object. */
88
+ function envAuthDb(env: Env): AuthDb {
89
+ return resolveAuthDb(env as unknown as Record<string, unknown>);
90
+ }
91
+
92
+ export const authRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
93
+
94
+ // Ensure errors propagate to parent app's error handler
95
+ authRoute.onError((err, c) => {
96
+ if (err instanceof EdgeBaseError) {
97
+ return c.json(err.toJSON(), err.code as 400);
98
+ }
99
+ // Duck-type fallback
100
+ const e = err as unknown as Record<string, unknown>;
101
+ if (typeof e.code === 'number' && e.code >= 400 && e.code < 600 && typeof e.message === 'string') {
102
+ const body: Record<string, unknown> = { code: e.code, message: e.message };
103
+ if (typeof e.slug === 'string') body.slug = e.slug;
104
+ return c.json(body, e.code as 400);
105
+ }
106
+ throw err; // Re-throw for parent error handler
107
+ });
108
+
109
+ // ─── Helpers ───
110
+
111
+ function requireAuth(auth: AuthContext | null): string {
112
+ if (!auth) {
113
+ throw new EdgeBaseError(401, 'Authentication required.', undefined, 'unauthenticated');
114
+ }
115
+ return auth.id;
116
+ }
117
+
118
+ type AuthAccessAction = Extract<keyof AuthAccess, string>;
119
+
120
+ async function ensureAuthActionAllowed(
121
+ c: { env: Env; req: { raw: Request }; get(name: string): unknown },
122
+ action: AuthAccessAction,
123
+ input: Record<string, unknown> | null,
124
+ ): Promise<void> {
125
+ const config = parseConfig(c.env);
126
+ const rule = getAuthAccess(config.auth)?.[action];
127
+ if (!rule) return;
128
+
129
+ const auth = (c.get('auth') as AuthContext | null | undefined) ?? null;
130
+ const allowed = await Promise.resolve(rule(input, {
131
+ request: c.req.raw,
132
+ auth: auth ? {
133
+ id: auth.id,
134
+ role: auth.role,
135
+ email: auth.email ?? undefined,
136
+ isAnonymous: auth.isAnonymous,
137
+ custom: auth.custom ?? undefined,
138
+ meta: auth.meta,
139
+ } : null,
140
+ ip: getClientIP(c.env, c.req.raw),
141
+ }));
142
+
143
+ if (!allowed) {
144
+ throw new EdgeBaseError(403, `Auth action '${action}' is not allowed.`, undefined, 'action-not-allowed');
145
+ }
146
+ }
147
+
148
+ function getUserSecret(env: Env): string {
149
+ if (!env.JWT_USER_SECRET) throw new EdgeBaseError(500, 'JWT_USER_SECRET is not configured. Set it in your environment variables to enable authentication.', undefined, 'internal-error');
150
+ return env.JWT_USER_SECRET;
151
+ }
152
+
153
+ function getAccessTokenTTL(env: Env): string {
154
+ const config = parseConfig(env);
155
+ return config?.auth?.session?.accessTokenTTL ?? '15m';
156
+ }
157
+
158
+ function getRefreshTokenTTL(env: Env): string {
159
+ const config = parseConfig(env);
160
+ return config?.auth?.session?.refreshTokenTTL ?? '28d';
161
+ }
162
+
163
+ function getMaxActiveSessions(env: Env): number {
164
+ const config = parseConfig(env);
165
+ return config?.auth?.session?.maxActiveSessions ?? 0; // 0 = unlimited
166
+ }
167
+
168
+ function isEmailProviderName(value: unknown): value is EmailConfig['provider'] {
169
+ return value === 'resend' || value === 'sendgrid' || value === 'mailgun' || value === 'ses';
170
+ }
171
+
172
+ function getEmailConfig(env: Env): EmailConfig | undefined {
173
+ const config = parseConfig(env);
174
+ const configured = (config as Record<string, unknown> | null)?.email as EmailConfig | undefined;
175
+ const runtimeEnv = env as unknown as Record<string, unknown>;
176
+
177
+ const provider = configured?.provider
178
+ ?? (isEmailProviderName(runtimeEnv.EDGEBASE_EMAIL_PROVIDER) ? runtimeEnv.EDGEBASE_EMAIL_PROVIDER : undefined);
179
+ const apiKey = configured?.apiKey
180
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_API_KEY === 'string' ? runtimeEnv.EDGEBASE_EMAIL_API_KEY : undefined);
181
+ const from = configured?.from
182
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_FROM === 'string' ? runtimeEnv.EDGEBASE_EMAIL_FROM : undefined);
183
+
184
+ if (!provider || !apiKey || !from) {
185
+ return configured;
186
+ }
187
+
188
+ return {
189
+ provider,
190
+ apiKey,
191
+ from,
192
+ domain: configured?.domain
193
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN === 'string' ? runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN : undefined),
194
+ region: configured?.region
195
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_SES_REGION === 'string' ? runtimeEnv.EDGEBASE_EMAIL_SES_REGION : undefined),
196
+ appName: configured?.appName ?? 'EdgeBase Local Auth Harness',
197
+ defaultLocale: configured?.defaultLocale,
198
+ verifyUrl: configured?.verifyUrl
199
+ ?? (typeof runtimeEnv.EDGEBASE_APP_WEB_VERIFY_EMAIL_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_VERIFY_EMAIL_URL : undefined),
200
+ resetUrl: configured?.resetUrl
201
+ ?? (typeof runtimeEnv.EDGEBASE_APP_WEB_RESET_PASSWORD_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_RESET_PASSWORD_URL : undefined),
202
+ magicLinkUrl: configured?.magicLinkUrl
203
+ ?? (typeof runtimeEnv.EDGEBASE_APP_WEB_MAGIC_LINK_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_MAGIC_LINK_URL : undefined),
204
+ emailChangeUrl: configured?.emailChangeUrl
205
+ ?? (typeof runtimeEnv.EDGEBASE_APP_WEB_CHANGE_EMAIL_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_CHANGE_EMAIL_URL : undefined),
206
+ templates: configured?.templates,
207
+ subjects: configured?.subjects,
208
+ };
209
+ }
210
+
211
+ function getAppName(env: Env): string {
212
+ return getEmailConfig(env)?.appName ?? 'EdgeBase';
213
+ }
214
+
215
+ /**
216
+ * Parse Accept-Language header to extract primary language code.
217
+ * "ko-KR,ko;q=0.9,en-US;q=0.8" → "ko"
218
+ */
219
+ function parseAcceptLanguage(header: string | undefined): string | undefined {
220
+ if (!header) return undefined;
221
+ const first = header.split(',')[0]?.trim().split(';')[0]?.trim();
222
+ return first?.split('-')[0] || undefined;
223
+ }
224
+
225
+ /**
226
+ * Resolve locale for email sending.
227
+ * Priority: user's stored locale > Accept-Language header > app default locale > 'en'
228
+ */
229
+ function resolveEmailLocale(env: Env, userLocale?: string | null, acceptLang?: string): string {
230
+ if (userLocale && userLocale !== 'en') return userLocale;
231
+ if (userLocale === 'en') return 'en';
232
+ if (acceptLang) return acceptLang;
233
+ return getEmailConfig(env)?.defaultLocale ?? 'en';
234
+ }
235
+
236
+ function getEmailTemplates(env: Env): EmailTemplateOverrides | undefined {
237
+ return getEmailConfig(env)?.templates;
238
+ }
239
+
240
+ /**
241
+ * Resolve LocalizedString to a plain string.
242
+ * LocalizedString = string | Record<string, string>.
243
+ * When locale is provided, tries: exact locale → base language → 'en' → first available.
244
+ */
245
+ function resolveLocalizedString(val: undefined, locale?: string): undefined;
246
+ function resolveLocalizedString(val: string | Record<string, string>, locale?: string): string;
247
+ function resolveLocalizedString(val: string | Record<string, string> | undefined, locale?: string): string | undefined;
248
+ function resolveLocalizedString(val: string | Record<string, string> | undefined, locale?: string): string | undefined {
249
+ if (val === undefined) return undefined;
250
+ if (typeof val === 'string') return val;
251
+ if (locale) {
252
+ const base = locale.split('-')[0];
253
+ const resolved = val[locale] ?? val[base] ?? val.en ?? Object.values(val)[0];
254
+ return resolved || undefined;
255
+ }
256
+ return val.en || Object.values(val)[0] || undefined;
257
+ }
258
+
259
+ function getEmailSubjects(env: Env): EmailSubjectOverrides | undefined {
260
+ return getEmailConfig(env)?.subjects;
261
+ }
262
+
263
+ function resolveSubject(env: Env, type: keyof EmailSubjectOverrides, defaultSubject: string, locale?: string): string {
264
+ const custom = getEmailSubjects(env)?.[type];
265
+ if (!custom) return defaultSubject;
266
+ // LocalizedString can be string or Record<string, string>
267
+ let subjectStr: string;
268
+ if (typeof custom === 'string') {
269
+ subjectStr = custom;
270
+ } else if (locale) {
271
+ const base = locale.split('-')[0];
272
+ subjectStr = custom[locale] ?? custom[base] ?? custom.en ?? Object.values(custom)[0] ?? defaultSubject;
273
+ } else {
274
+ subjectStr = custom.en || Object.values(custom)[0] || defaultSubject;
275
+ }
276
+ return subjectStr.replace(/\{\{appName\}\}/g, getAppName(env));
277
+ }
278
+
279
+ function getMagicLinkConfig(env: Env): MagicLinkConfig | undefined {
280
+ const config = parseConfig(env);
281
+ return config?.auth?.magicLink;
282
+ }
283
+
284
+ function getEmailOtpConfig(env: Env): EmailOtpConfig | undefined {
285
+ const config = parseConfig(env);
286
+ return config?.auth?.emailOtp;
287
+ }
288
+
289
+ function getSmsConfig(env: Env): SmsConfig | undefined {
290
+ const config = parseConfig(env);
291
+ return (config as Record<string, unknown> | null)?.sms as SmsConfig | undefined;
292
+ }
293
+
294
+ function getMfaConfig(env: Env): MfaConfig | undefined {
295
+ const config = parseConfig(env);
296
+ return config?.auth?.mfa;
297
+ }
298
+
299
+ function getPasswordPolicyConfig(env: Env): PasswordPolicyConfig | undefined {
300
+ const config = parseConfig(env);
301
+ return config?.auth?.passwordPolicy;
302
+ }
303
+
304
+ function getPasskeysConfig(env: Env): PasskeysConfig | undefined {
305
+ const config = parseConfig(env);
306
+ return config?.auth?.passkeys;
307
+ }
308
+
309
+ type OAuthIdentityRecord = {
310
+ id: string;
311
+ provider: string;
312
+ providerUserId: string;
313
+ createdAt: string;
314
+ };
315
+
316
+ async function getIdentityState(env: Env, db: AuthDb, userId: string): Promise<{
317
+ user: Record<string, unknown>;
318
+ oauthAccounts: OAuthIdentityRecord[];
319
+ passkeyCount: number;
320
+ summary: {
321
+ total: number;
322
+ hasPassword: boolean;
323
+ hasMagicLink: boolean;
324
+ hasEmailOtp: boolean;
325
+ hasPhone: boolean;
326
+ passkeyCount: number;
327
+ oauthCount: number;
328
+ };
329
+ }> {
330
+ const user = await authService.getUserById(db, userId);
331
+ if (!user) {
332
+ throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
333
+ }
334
+
335
+ const oauthAccounts = (await authService.listOAuthAccounts(db, userId)).map((row) => ({
336
+ id: String(row.id),
337
+ provider: String(row.provider),
338
+ providerUserId: String(row.providerUserId),
339
+ createdAt: String(row.createdAt),
340
+ }));
341
+
342
+ const passkeyCount = getPasskeysConfig(env)?.enabled
343
+ ? (await authService.listWebAuthnCredentials(db, userId)).length
344
+ : 0;
345
+
346
+ const hasPassword =
347
+ parseConfig(env)?.auth?.emailAuth !== false
348
+ && typeof user.passwordHash === 'string'
349
+ && user.passwordHash.length > 0;
350
+ const hasMagicLink =
351
+ typeof user.email === 'string'
352
+ && user.email.length > 0
353
+ && !!getMagicLinkConfig(env)?.enabled;
354
+ const hasEmailOtp =
355
+ typeof user.email === 'string'
356
+ && user.email.length > 0
357
+ && !!getEmailOtpConfig(env)?.enabled;
358
+ const hasPhone =
359
+ !!parseConfig(env)?.auth?.phoneAuth
360
+ && typeof user.phone === 'string'
361
+ && user.phone.length > 0
362
+ && Number(user.phoneVerified) === 1;
363
+
364
+ const total =
365
+ Number(hasPassword)
366
+ + Number(hasMagicLink)
367
+ + Number(hasEmailOtp)
368
+ + Number(hasPhone)
369
+ + passkeyCount
370
+ + oauthAccounts.length;
371
+
372
+ return {
373
+ user,
374
+ oauthAccounts,
375
+ passkeyCount,
376
+ summary: {
377
+ total,
378
+ hasPassword,
379
+ hasMagicLink,
380
+ hasEmailOtp,
381
+ hasPhone,
382
+ passkeyCount,
383
+ oauthCount: oauthAccounts.length,
384
+ },
385
+ };
386
+ }
387
+
388
+ function generateOTP(): string {
389
+ const buf = new Uint32Array(1);
390
+ crypto.getRandomValues(buf);
391
+ return String(buf[0] % 1000000).padStart(6, '0');
392
+ }
393
+
394
+ function parseTTLtoMs(ttl: string): number {
395
+ const match = ttl.match(/^(\d+)(s|m|h|d)$/);
396
+ if (!match) return 15 * 60 * 1000; // default 15m
397
+ const value = parseInt(match[1], 10);
398
+ switch (match[2]) {
399
+ case 's': return value * 1000;
400
+ case 'm': return value * 60 * 1000;
401
+ case 'h': return value * 60 * 60 * 1000;
402
+ case 'd': return value * 24 * 60 * 60 * 1000;
403
+ default: return 15 * 60 * 1000;
404
+ }
405
+ }
406
+
407
+ async function hashRecoveryCode(code: string): Promise<string> {
408
+ const encoded = new TextEncoder().encode(code);
409
+ const hash = await crypto.subtle.digest('SHA-256', encoded);
410
+ const bytes = new Uint8Array(hash);
411
+ let hex = '';
412
+ for (const b of bytes) {
413
+ hex += b.toString(16).padStart(2, '0');
414
+ }
415
+ return hex;
416
+ }
417
+
418
+ async function verifyRecoveryCode(code: string, storedHash: string): Promise<boolean> {
419
+ const hash = await hashRecoveryCode(code);
420
+ if (hash.length !== storedHash.length) return false;
421
+ let result = 0;
422
+ for (let i = 0; i < hash.length; i++) {
423
+ result |= hash.charCodeAt(i) ^ storedHash.charCodeAt(i);
424
+ }
425
+ return result === 0;
426
+ }
427
+
428
+ /**
429
+ * Generate access token from user record, merging DB customClaims and hook overrides.
430
+ */
431
+ async function generateAccessToken(
432
+ env: Env,
433
+ user: Record<string, unknown>,
434
+ hookClaimsOverride?: Record<string, unknown>,
435
+ ): Promise<string> {
436
+ const dbClaims = user.customClaims
437
+ ? (typeof user.customClaims === 'string' ? JSON.parse(user.customClaims as string) : user.customClaims)
438
+ : undefined;
439
+
440
+ let finalClaims = dbClaims;
441
+ if (hookClaimsOverride) {
442
+ finalClaims = { ...(dbClaims || {}), ...hookClaimsOverride };
443
+ const SYSTEM_CLAIMS = ['sub', 'iss', 'exp', 'iat', 'isAnonymous', 'displayName'];
444
+ for (const key of SYSTEM_CLAIMS) {
445
+ if (key in finalClaims) delete finalClaims[key];
446
+ }
447
+ }
448
+
449
+ return signAccessToken(
450
+ {
451
+ sub: user.id as string,
452
+ email: user.email as string | null,
453
+ displayName: (user.displayName as string | null) ?? undefined,
454
+ role: user.role as string,
455
+ isAnonymous: (typeof user.isAnonymous === 'number') ? user.isAnonymous === 1 : !!user.isAnonymous,
456
+ custom: finalClaims,
457
+ },
458
+ getUserSecret(env),
459
+ getAccessTokenTTL(env),
460
+ );
461
+ }
462
+
463
+ /**
464
+ * Create a session with eviction and token generation — D1-based.
465
+ */
466
+ async function createSessionAndTokens(
467
+ env: Env,
468
+ userId: string,
469
+ ip: string,
470
+ userAgent: string,
471
+ ): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
472
+ const db = envAuthDb(env);
473
+ const user = await authService.getUserById(db, userId);
474
+ if (!user) throw new EdgeBaseError(500, 'Internal error: user record was not found immediately after creation.', undefined, 'internal-error');
475
+
476
+ // Session limit eviction
477
+ const maxSessions = getMaxActiveSessions(env);
478
+ if (maxSessions > 0) {
479
+ await authService.evictOldestSessions(db, userId, maxSessions);
480
+ }
481
+
482
+ const accessToken = await generateAccessToken(env, user);
483
+ const sessionId = generateId();
484
+ const refreshToken = await signRefreshToken(
485
+ { sub: userId, type: 'refresh', jti: sessionId },
486
+ getUserSecret(env),
487
+ getRefreshTokenTTL(env),
488
+ );
489
+ const now = new Date().toISOString();
490
+ const refreshTTLSeconds = parseDuration(getRefreshTokenTTL(env));
491
+ const expiresAt = new Date(Date.now() + refreshTTLSeconds * 1000).toISOString();
492
+
493
+ const metadata = JSON.stringify({ ip, userAgent, lastActiveAt: now });
494
+
495
+ await authService.createSession(db, {
496
+ id: sessionId,
497
+ userId,
498
+ refreshToken,
499
+ expiresAt,
500
+ metadata,
501
+ });
502
+
503
+ return { accessToken, refreshToken, sessionId };
504
+ }
505
+
506
+ /**
507
+ * Rotate refresh token — D1-based.
508
+ */
509
+ async function rotateRefreshTokenFlow(
510
+ env: Env,
511
+ ctx: ExecutionContext,
512
+ session: Record<string, unknown>,
513
+ userId: string,
514
+ workerUrl?: string,
515
+ ): Promise<{ user: Record<string, unknown>; accessToken: string; refreshToken: string }> {
516
+ const db = envAuthDb(env);
517
+ const user = await authService.getUserById(db, userId);
518
+ if (!user) throw new EdgeBaseError(401, 'User not found.', undefined, 'invalid-credentials');
519
+
520
+ if (user.disabled === 1) {
521
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
522
+ }
523
+
524
+ const newRefreshToken = await signRefreshToken(
525
+ { sub: userId, type: 'refresh', jti: generateId() },
526
+ getUserSecret(env),
527
+ getRefreshTokenTTL(env),
528
+ );
529
+ const refreshTTLSeconds = parseDuration(getRefreshTokenTTL(env));
530
+ const expiresAt = new Date(Date.now() + refreshTTLSeconds * 1000).toISOString();
531
+
532
+ await authService.rotateRefreshToken(
533
+ db,
534
+ session.id as string,
535
+ newRefreshToken,
536
+ session.refreshToken as string,
537
+ expiresAt,
538
+ );
539
+
540
+ // onTokenRefresh hook — blocking, returns custom claims
541
+ let hookClaims: Record<string, unknown> | undefined;
542
+ try {
543
+ const result = await executeAuthHook(env, ctx, 'onTokenRefresh', authService.sanitizeUser(user), { blocking: true, workerUrl });
544
+ if (result && typeof result === 'object') {
545
+ hookClaims = result;
546
+ }
547
+ } catch {
548
+ console.error('[EdgeBase] onTokenRefresh hook failed, proceeding without hook claims');
549
+ }
550
+
551
+ const accessToken = await generateAccessToken(env, user, hookClaims);
552
+
553
+ return {
554
+ user: authService.sanitizeUser(user),
555
+ accessToken,
556
+ refreshToken: newRefreshToken,
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Sync user data to _users_public and KV cache.
562
+ */
563
+ function syncUserPublic(
564
+ env: Env,
565
+ ctx: ExecutionContext,
566
+ userId: string,
567
+ userData: Record<string, unknown>,
568
+ isSync: boolean = false,
569
+ ): Promise<void> | void {
570
+ const authDb = envAuthDb(env);
571
+ if (isSync) {
572
+ return syncPublicUserProjection(authDb, userId, userData, {
573
+ executionCtx: ctx,
574
+ kv: env.KV,
575
+ awaitCacheWrites: true,
576
+ });
577
+ }
578
+ queuePublicUserProjectionSync(authDb, userId, userData, {
579
+ executionCtx: ctx,
580
+ kv: env.KV,
581
+ });
582
+ }
583
+
584
+ /**
585
+ * Execute auth hooks for a given event — D1-based (no re-entrancy concern).
586
+ */
587
+ export async function executeAuthHook(
588
+ env: Env,
589
+ ctx: ExecutionContext,
590
+ event: AuthTrigger['event'],
591
+ userData: Record<string, unknown>,
592
+ options: { blocking?: boolean; ip?: string; userAgent?: string; workerUrl?: string } = {},
593
+ ): Promise<Record<string, unknown> | void> {
594
+ const functions = getFunctionsByTrigger('auth', { type: 'auth', event } as AuthTrigger);
595
+ if (functions.length === 0) return;
596
+
597
+ const HOOK_TIMEOUT_MS = 5000;
598
+ const config = parseConfig(env);
599
+ const serviceKey = resolveRootServiceKey(config, env);
600
+ const adminDb = buildAdminDbProxy({
601
+ databaseNamespace: env.DATABASE,
602
+ config,
603
+ workerUrl: options.workerUrl,
604
+ serviceKey,
605
+ env,
606
+ executionCtx: ctx,
607
+ });
608
+ const authAdminBase = buildAdminAuthContext({
609
+ d1Database: env.AUTH_DB,
610
+ serviceKey,
611
+ workerUrl: options.workerUrl,
612
+ kvNamespace: env.KV,
613
+ });
614
+ const mergedBlockingResult: Record<string, unknown> = {};
615
+
616
+ for (const { name, definition } of functions) {
617
+ try {
618
+ const authAdmin = {
619
+ ...authAdminBase,
620
+ async createUser(_data: {
621
+ email: string;
622
+ password: string;
623
+ displayName?: string;
624
+ role?: string;
625
+ }) {
626
+ throw new Error(
627
+ 'admin.auth.createUser() is not available inside auth hooks. ' +
628
+ 'Use the Admin API or SDK for user creation.',
629
+ );
630
+ },
631
+ async deleteUser(_userId: string) {
632
+ throw new Error(
633
+ 'admin.auth.deleteUser() is not available inside auth hooks. ' +
634
+ 'Use the Admin API or SDK for user deletion.',
635
+ );
636
+ },
637
+ };
638
+
639
+ const hookCtx: Record<string, unknown> = {
640
+ request: new Request('http://internal/auth-hook'),
641
+ auth: null,
642
+ admin: {
643
+ db: adminDb,
644
+ table: (name: string) => adminDb('shared').table(name),
645
+ auth: authAdmin,
646
+ async sql(namespace: string, id: string | undefined, query: string, params?: unknown[]) {
647
+ if (options.workerUrl && serviceKey) {
648
+ const res = await fetch(`${options.workerUrl}/api/sql`, {
649
+ method: 'POST',
650
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': serviceKey },
651
+ body: JSON.stringify({ namespace, id, sql: query, params: params ?? [] }),
652
+ });
653
+ if (!res.ok) throw new Error(`admin.sql() failed: ${res.status}`);
654
+ return res.json();
655
+ }
656
+ throw new Error('admin.sql() requires workerUrl in auth hook context.');
657
+ },
658
+ async broadcast(channel: string, event: string, payload?: Record<string, unknown>) {
659
+ if (options.workerUrl && serviceKey) {
660
+ await fetch(`${options.workerUrl}/api/db/broadcast`, {
661
+ method: 'POST',
662
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': serviceKey },
663
+ body: JSON.stringify({ channel, event, payload: payload ?? {} }),
664
+ });
665
+ return;
666
+ }
667
+ throw new Error('admin.broadcast() requires workerUrl in auth hook context.');
668
+ },
669
+ functions: {
670
+ async call(name: string, data?: unknown) {
671
+ if (options.workerUrl && serviceKey) {
672
+ const safeName = name.split('/').map(encodeURIComponent).join('/');
673
+ const res = await fetch(`${options.workerUrl}/api/functions/${safeName}`, {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': serviceKey },
676
+ body: JSON.stringify(data ?? {}),
677
+ });
678
+ if (!res.ok) throw new Error(`admin.functions.call('${name}') failed: ${res.status}`);
679
+ return res.json();
680
+ }
681
+ throw new Error('admin.functions.call() requires workerUrl in auth hook context.');
682
+ },
683
+ },
684
+ kv: (namespace: string) => buildFunctionKvProxy(namespace, config, env, options.workerUrl, serviceKey),
685
+ d1: (database: string) => buildFunctionD1Proxy(database, config, env, options.workerUrl, serviceKey),
686
+ vector: (index: string) => buildFunctionVectorizeProxy(index, config, env, options.workerUrl, serviceKey),
687
+ push: buildFunctionPushProxy(options.workerUrl, serviceKey),
688
+ },
689
+ data: { after: userData },
690
+ ...(options.ip ? { ip: options.ip } : {}),
691
+ ...(options.userAgent ? { userAgent: options.userAgent } : {}),
692
+ };
693
+
694
+ if (options.blocking) {
695
+ const result = await Promise.race([
696
+ definition.handler(hookCtx),
697
+ new Promise((_, reject) =>
698
+ setTimeout(() => reject(new Error(`Auth hook '${name}' timed out (5s)`)), HOOK_TIMEOUT_MS),
699
+ ),
700
+ ]);
701
+ if (result && typeof result === 'object') {
702
+ Object.assign(mergedBlockingResult, result as Record<string, unknown>);
703
+ }
704
+ } else {
705
+ ctx.waitUntil(
706
+ definition.handler(hookCtx).catch((err: unknown) => {
707
+ console.error(`[EdgeBase] Auth hook '${name}' (${event}) failed:`, err);
708
+ }),
709
+ );
710
+ }
711
+ } catch (err) {
712
+ if (options.blocking) {
713
+ console.error(`[EdgeBase] Blocking auth hook '${name}' (${event}) failed:`, err);
714
+ throw new EdgeBaseError(403, `Auth hook '${name}' rejected the operation.`, undefined, 'hook-rejected');
715
+ }
716
+ console.error(`[EdgeBase] Auth hook '${name}' (${event}) error:`, err);
717
+ }
718
+ }
719
+
720
+ if (options.blocking && Object.keys(mergedBlockingResult).length > 0) {
721
+ return mergedBlockingResult;
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Send email with optional auth.handlers.email.onSend interception.
727
+ * The optional `locale` parameter is passed through to the onSend hook.
728
+ */
729
+ async function sendMailWithHook(
730
+ env: Env,
731
+ ctx: ExecutionContext,
732
+ provider: EmailProvider,
733
+ type: MailType,
734
+ to: string,
735
+ subject: string,
736
+ html: string,
737
+ locale?: string,
738
+ ): Promise<{ success: boolean; messageId?: string }> {
739
+ const config = parseConfig(env);
740
+ const onSend = getAuthHandlers(config)?.email?.onSend;
741
+
742
+ let finalSubject = subject;
743
+ let finalHtml = html;
744
+
745
+ if (onSend) {
746
+ const MAIL_HOOK_TIMEOUT = 5000;
747
+ const mailCtx: MailHookCtx = {
748
+ waitUntil: (p: Promise<unknown>) => ctx.waitUntil(p),
749
+ };
750
+
751
+ try {
752
+ const result = await Promise.race([
753
+ Promise.resolve(onSend(type, to, finalSubject, finalHtml, mailCtx, locale)),
754
+ new Promise<never>((_, reject) =>
755
+ setTimeout(() => reject(new Error('Mail hook timed out')), MAIL_HOOK_TIMEOUT),
756
+ ),
757
+ ]);
758
+
759
+ if (result) {
760
+ if (result.subject) finalSubject = result.subject;
761
+ if (result.html) finalHtml = result.html;
762
+ }
763
+ } catch (err) {
764
+ console.error('[EdgeBase] auth.handlers.email.onSend rejected or timed out:', err);
765
+ throw new EdgeBaseError(403, 'Mail hook rejected the email.', undefined, 'hook-rejected');
766
+ }
767
+ }
768
+
769
+ return provider.send({ to, subject: finalSubject, html: finalHtml });
770
+ }
771
+
772
+ async function sendSmsWithHook(
773
+ env: Env,
774
+ ctx: ExecutionContext,
775
+ provider: SmsProvider,
776
+ type: SmsType,
777
+ to: string,
778
+ body: string,
779
+ ): Promise<void> {
780
+ const onSend = getAuthHandlers(parseConfig(env))?.sms?.onSend;
781
+ let finalBody = body;
782
+
783
+ if (onSend) {
784
+ const SMS_HOOK_TIMEOUT = 5000;
785
+ const smsCtx: SmsHookCtx = {
786
+ waitUntil: (p: Promise<unknown>) => ctx.waitUntil(p),
787
+ };
788
+
789
+ try {
790
+ const result = await Promise.race([
791
+ Promise.resolve(onSend(type, to, finalBody, smsCtx)),
792
+ new Promise<never>((_, reject) =>
793
+ setTimeout(() => reject(new Error('SMS hook timed out')), SMS_HOOK_TIMEOUT),
794
+ ),
795
+ ]);
796
+
797
+ if (result?.body) {
798
+ finalBody = result.body;
799
+ }
800
+ } catch (err) {
801
+ console.error('[EdgeBase] auth.handlers.sms.onSend rejected or timed out:', err);
802
+ throw new EdgeBaseError(403, 'SMS hook rejected the SMS.', undefined, 'hook-rejected');
803
+ }
804
+ }
805
+
806
+ await provider.send({ to, body: finalBody });
807
+ }
808
+
809
+ /**
810
+ * Extract client IP from request headers.
811
+ *
812
+ * Priority: CF-Connecting-IP (Cloudflare, tamper-proof) →
813
+ * X-Forwarded-For (only when trustSelfHostedProxy=true).
814
+ *
815
+ * Security: X-Forwarded-For is client-spoofable when EdgeBase is exposed without
816
+ * a reverse proxy. Self-hosted deployments MUST place EdgeBase behind Nginx/Caddy
817
+ * that overwrites X-Forwarded-For with $remote_addr. See docs/self-hosting.md.
818
+ */
819
+ function getClientIP(env: Env, request: Request): string {
820
+ return getTrustedClientIp(env, request) ?? '0.0.0.0';
821
+ }
822
+
823
+ function getAnonymousAuthEnabled(env: Env): boolean {
824
+ try {
825
+ const config = parseConfig(env);
826
+ return !!config?.auth?.anonymousAuth;
827
+ } catch {
828
+ return false;
829
+ }
830
+ }
831
+
832
+ // ─── D1 Schema Middleware ───
833
+
834
+ authRoute.use('*', async (c, next) => {
835
+ await ensureAuthSchema(getAuthDb(c));
836
+ await next();
837
+ });
838
+
839
+ // ─── Auth Rate Limiting Middleware ───
840
+ // 2-layer: software counter (config-driven) + Binding ceiling
841
+ // Service Key는 auth 그룹 바이패스
842
+
843
+ authRoute.use('*', async (c, next) => {
844
+ // Service Key bypasses auth rate limit
845
+ const providedServiceKey = resolveServiceKeyCandidate(
846
+ c.req,
847
+ c.get('serviceKeyToken') as string | null | undefined,
848
+ );
849
+ if (providedServiceKey) {
850
+ const config = c.env ? parseConfig(c.env) : {};
851
+ const { result: skResult } = validateKey(
852
+ providedServiceKey,
853
+ 'auth:*:*:bypass',
854
+ config,
855
+ c.env as never,
856
+ undefined,
857
+ buildConstraintCtx((c.env ?? {}) as { ENVIRONMENT?: string }, c.req),
858
+ );
859
+ if (skResult === 'valid') {
860
+ await next();
861
+ return;
862
+ }
863
+ // 'invalid' key provided — still fall through to normal rate limiting
864
+ // (don't throw here; auth routes return their own errors)
865
+ }
866
+
867
+ const ip = getClientIP(c.env, c.req.raw);
868
+ const config = c.env ? parseConfig(c.env) : undefined;
869
+
870
+ // Layer 1: Software counter (config-driven)
871
+ const { requests, windowSec } = getLimit(config, 'auth');
872
+ const counterKey = `auth:${ip}`;
873
+ if (!counter.check(counterKey, requests, windowSec)) {
874
+ throw new EdgeBaseError(429, 'Too many requests. Try again later.', undefined, 'rate-limited');
875
+ }
876
+
877
+ // Layer 2: Binding ceiling
878
+ const authLimiter = c.env?.AUTH_RATE_LIMITER;
879
+ if (authLimiter) {
880
+ const { success } = await authLimiter.limit({ key: ip });
881
+ if (!success) {
882
+ throw new EdgeBaseError(429, 'Too many requests. Try again later.', undefined, 'rate-limited');
883
+ }
884
+ }
885
+ await next();
886
+ });
887
+
888
+ // ─── Captcha Middleware ───
889
+ // Applied per-route after rate limiting. Service Key requests bypass.
890
+ authRoute.use('/signup', captchaMiddleware('signup'));
891
+ authRoute.use('/signin', captchaMiddleware('signin'));
892
+ authRoute.use('/signin/anonymous', captchaMiddleware('anonymous'));
893
+ authRoute.use('/signin/magic-link', captchaMiddleware('magic-link'));
894
+ authRoute.use('/signin/phone', captchaMiddleware('phone'));
895
+ authRoute.use('/request-password-reset', captchaMiddleware('password-reset'));
896
+
897
+ // ─── Signup (D1 Control Plane) ───
898
+
899
+ const signup = createRoute({
900
+ operationId: 'authSignup',
901
+ method: 'post',
902
+ path: '/signup',
903
+ tags: ['client'],
904
+ summary: 'Sign up with email and password',
905
+ request: {
906
+ body: { content: { 'application/json': { schema: z.object({
907
+ email: z.string(),
908
+ password: z.string(),
909
+ }).passthrough() } }, required: true },
910
+ },
911
+ responses: {
912
+ 201: { description: 'User created', content: { 'application/json': { schema: jsonResponseSchema } } },
913
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
914
+ 409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
915
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
916
+ },
917
+ });
918
+
919
+ authRoute.openapi(signup, async (c) => {
920
+ const ip = getClientIP(c.env, c.req.raw);
921
+ const config = c.env ? parseConfig(c.env) : undefined;
922
+
923
+ // Layer 1: Software counter (config-driven)
924
+ const { requests, windowSec } = getLimit(config, 'authSignup');
925
+ const counterKey = `authSignup:${ip}`;
926
+ if (!counter.check(counterKey, requests, windowSec)) {
927
+ throw new EdgeBaseError(429, 'Too many signup attempts. Please try again later.', undefined, 'rate-limited');
928
+ }
929
+
930
+ // Layer 2: Binding ceiling
931
+ const signupLimiter = c.env?.AUTH_SIGNUP_RATE_LIMITER;
932
+ if (signupLimiter) {
933
+ const { success } = await signupLimiter.limit({ key: ip });
934
+ if (!success) {
935
+ throw new EdgeBaseError(429, 'Too many signup attempts. Please try again later.', undefined, 'rate-limited');
936
+ }
937
+ }
938
+
939
+ const body = await c.req.json<{ email: string; password: string; data?: Record<string, unknown> }>();
940
+ if (!body.email || !body.password) {
941
+ throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'invalid-input');
942
+ }
943
+ body.email = body.email.trim().toLowerCase(); //
944
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
945
+ throw new EdgeBaseError(400, 'Invalid email format. Please provide a valid email address.', undefined, 'invalid-email');
946
+ }
947
+ if (body.password.length < 8) {
948
+ throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
949
+ }
950
+ if (body.password.length > 256) {
951
+ throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
952
+ }
953
+
954
+ await ensureAuthActionAllowed(c, 'signUp', body as unknown as Record<string, unknown>);
955
+
956
+ // Validate optional fields from body.data
957
+ const displayName = body.data?.displayName ?? null;
958
+ const avatarUrl = body.data?.avatarUrl ?? null;
959
+ if (displayName !== null && typeof displayName === 'string' && displayName.length > 200) {
960
+ throw new EdgeBaseError(400, 'Display name must not exceed 200 characters.', undefined, 'display-name-too-long');
961
+ }
962
+ if (avatarUrl !== null && typeof avatarUrl === 'string' && avatarUrl.length > 2048) {
963
+ throw new EdgeBaseError(400, 'Avatar URL must not exceed 2048 characters.', undefined, 'invalid-input');
964
+ }
965
+
966
+ // Validate locale if provided
967
+ const rawLocale = (body as Record<string, unknown>).locale as string | undefined;
968
+ if (rawLocale && !/^[a-z]{2}(-[A-Z]{2})?$/.test(rawLocale)) {
969
+ throw new EdgeBaseError(400, 'Invalid locale format. Expected format: "en" or "en-US".', undefined, 'invalid-locale');
970
+ }
971
+ const locale = rawLocale ?? parseAcceptLanguage(c.req.header('accept-language')) ?? 'en';
972
+
973
+ const userId = generateId();
974
+ const db = getAuthDb(c);
975
+
976
+ // Register pending in D1 email index
977
+ try {
978
+ await registerEmailPending(db, body.email, userId);
979
+ } catch (err) {
980
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
981
+ throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
982
+ }
983
+ throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
984
+ }
985
+
986
+ // Create user directly in D1
987
+ try {
988
+ // Password policy validation
989
+ const policyResult = await validatePassword(body.password, getPasswordPolicyConfig(c.env));
990
+ if (!policyResult.valid) {
991
+ await deleteEmailPending(db, body.email).catch(() => {});
992
+ throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
993
+ }
994
+
995
+ const passwordHash = await hashPassword(body.password);
996
+
997
+ await authService.createUser(db, {
998
+ userId,
999
+ email: body.email,
1000
+ passwordHash,
1001
+ displayName: displayName as string | null,
1002
+ avatarUrl: avatarUrl as string | null,
1003
+ emailVisibility: 'private',
1004
+ role: 'user',
1005
+ verified: false,
1006
+ locale,
1007
+ });
1008
+
1009
+ // beforeSignUp hook — blocking, can cancel signup
1010
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1011
+ id: userId,
1012
+ email: body.email,
1013
+ displayName,
1014
+ avatarUrl,
1015
+ }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1016
+
1017
+ // Create session + tokens
1018
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
1019
+
1020
+ // Confirm email in D1 index
1021
+ await confirmEmail(db, body.email, userId);
1022
+
1023
+ // Sync to _users_public
1024
+ const user = await authService.getUserById(db, userId);
1025
+ if (user) {
1026
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
1027
+
1028
+ // afterSignUp hook — non-blocking
1029
+ c.executionCtx.waitUntil(
1030
+ executeAuthHook(c.env, c.executionCtx, 'afterSignUp', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
1031
+ );
1032
+
1033
+ return c.json({
1034
+ user: authService.sanitizeUser(user),
1035
+ accessToken: session.accessToken,
1036
+ refreshToken: session.refreshToken,
1037
+ }, 201);
1038
+ }
1039
+
1040
+ return c.json({ accessToken: session.accessToken, refreshToken: session.refreshToken }, 201);
1041
+ } catch (err) {
1042
+ if (err instanceof EdgeBaseError) throw err;
1043
+ // Compensating transaction
1044
+ await deleteEmailPending(db, body.email).catch(() => {});
1045
+ throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
1046
+ }
1047
+ });
1048
+
1049
+ // ─── Signin (D1 Control Plane) ───
1050
+
1051
+ const signin = createRoute({
1052
+ operationId: 'authSignin',
1053
+ method: 'post',
1054
+ path: '/signin',
1055
+ tags: ['client'],
1056
+ summary: 'Sign in with email and password',
1057
+ request: {
1058
+ body: { content: { 'application/json': { schema: z.object({
1059
+ email: z.string(),
1060
+ password: z.string(),
1061
+ }).passthrough() } }, required: true },
1062
+ },
1063
+ responses: {
1064
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1065
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1066
+ 401: { description: 'Invalid credentials', content: { 'application/json': { schema: errorResponseSchema } } },
1067
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
1068
+ },
1069
+ });
1070
+
1071
+ authRoute.openapi(signin, async (c) => {
1072
+ const body = await c.req.json<{ email: string; password: string }>();
1073
+ if (!body.email || !body.password) {
1074
+ throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'invalid-input');
1075
+ }
1076
+ body.email = body.email.trim().toLowerCase();
1077
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
1078
+ throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
1079
+ }
1080
+ if (body.password.length > 256) {
1081
+ throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
1082
+ }
1083
+
1084
+ await ensureAuthActionAllowed(c, 'signIn', body as unknown as Record<string, unknown>);
1085
+
1086
+ // Layer 1: Software counter (config-driven, email당)
1087
+ const config = c.env ? parseConfig(c.env) : undefined;
1088
+ const signinLimit = getLimit(config, 'authSignin');
1089
+ const signinKey = `authSignin:${body.email}`;
1090
+ if (!counter.check(signinKey, signinLimit.requests, signinLimit.windowSec)) {
1091
+ throw new EdgeBaseError(429, 'Too many login attempts. Try again later.', undefined, 'rate-limited');
1092
+ }
1093
+
1094
+ // Layer 2: Binding ceiling
1095
+ const signinLimiter = c.env?.AUTH_SIGNIN_RATE_LIMITER;
1096
+ if (signinLimiter) {
1097
+ const { success } = await signinLimiter.limit({ key: body.email });
1098
+ if (!success) {
1099
+ throw new EdgeBaseError(429, 'Too many login attempts. Try again later.', undefined, 'rate-limited');
1100
+ }
1101
+ }
1102
+
1103
+ // Look up email → userId in D1
1104
+ const record = await lookupEmail(getAuthDb(c), body.email);
1105
+ if (!record) {
1106
+ throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
1107
+ }
1108
+
1109
+ const { userId } = record;
1110
+ const ip = getClientIP(c.env, c.req.raw);
1111
+ const db = getAuthDb(c);
1112
+
1113
+ // Verify password directly in D1
1114
+ const user = await authService.getUserById(db, userId);
1115
+ if (!user) {
1116
+ throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
1117
+ }
1118
+
1119
+ // OAuth-only user check
1120
+ if (!user.passwordHash) {
1121
+ throw new EdgeBaseError(403, 'This account uses OAuth sign-in. Password login is not available.', undefined, 'oauth-only');
1122
+ }
1123
+
1124
+ const valid = await verifyPassword(body.password, user.passwordHash as string);
1125
+ if (!valid) {
1126
+ throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
1127
+ }
1128
+
1129
+ // Lazy re-hash: if password uses non-native format (e.g. imported bcrypt), upgrade to PBKDF2
1130
+ if (needsRehash(user.passwordHash as string)) {
1131
+ const newHash = await hashPassword(body.password);
1132
+ await authService.updateUser(db, userId, { passwordHash: newHash });
1133
+ }
1134
+
1135
+ // Disabled user check
1136
+ if (user.disabled === 1) {
1137
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
1138
+ }
1139
+
1140
+ // beforeSignIn hook — blocking, can reject signin
1141
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1142
+
1143
+ // MFA Check
1144
+ const mfaConfig = getMfaConfig(c.env);
1145
+ if (mfaConfig?.totp) {
1146
+ const factors = await authService.listVerifiedMfaFactors(db, userId);
1147
+ if (factors.length > 0) {
1148
+ const mfaTicket = crypto.randomUUID();
1149
+ await c.env.KV.put(
1150
+ `mfa-ticket:${mfaTicket}`,
1151
+ JSON.stringify({ userId }),
1152
+ { expirationTtl: 300 },
1153
+ );
1154
+
1155
+ return c.json({
1156
+ mfaRequired: true,
1157
+ mfaTicket,
1158
+ factors: factors.map((f: Record<string, unknown>) => ({ id: f.id, type: f.type })),
1159
+ });
1160
+ }
1161
+ }
1162
+
1163
+ // Lazy cleanup of expired sessions
1164
+ await authService.cleanExpiredSessionsForUser(db, userId);
1165
+
1166
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
1167
+
1168
+ // afterSignIn hook — non-blocking
1169
+ c.executionCtx.waitUntil(
1170
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
1171
+ );
1172
+
1173
+ return c.json({
1174
+ user: authService.sanitizeUser(user),
1175
+ accessToken: session.accessToken,
1176
+ refreshToken: session.refreshToken,
1177
+ });
1178
+ });
1179
+
1180
+ // ─── Anonymous Signin (D1 Control Plane) ───
1181
+
1182
+ const signinAnonymous = createRoute({
1183
+ operationId: 'authSigninAnonymous',
1184
+ method: 'post',
1185
+ path: '/signin/anonymous',
1186
+ tags: ['client'],
1187
+ summary: 'Sign in anonymously',
1188
+ request: {
1189
+ body: { content: { 'application/json': { schema: z.object({
1190
+ captchaToken: z.string().optional(),
1191
+ }).passthrough() } }, required: false },
1192
+ },
1193
+ responses: {
1194
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1195
+ 404: { description: 'Anonymous auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
1196
+ },
1197
+ });
1198
+
1199
+ authRoute.openapi(signinAnonymous, async (c) => {
1200
+ if (!getAnonymousAuthEnabled(c.env)) {
1201
+ throw new EdgeBaseError(404, 'Anonymous authentication is not enabled.', undefined, 'feature-not-enabled');
1202
+ }
1203
+
1204
+ const rawBody = await c.req.json<Record<string, unknown>>().catch(() => ({}));
1205
+ await ensureAuthActionAllowed(c, 'signInAnonymous', rawBody);
1206
+
1207
+ const ip = getClientIP(c.env, c.req.raw);
1208
+
1209
+ const userId = generateId();
1210
+ const db = getAuthDb(c);
1211
+
1212
+ // Register in D1 _anon_index
1213
+ await registerAnonPending(db, userId);
1214
+
1215
+ try {
1216
+ // Create anonymous user directly in D1
1217
+ await authService.createAnonymousUser(db, userId);
1218
+
1219
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
1220
+
1221
+ // Confirm in D1
1222
+ await confirmAnon(db, userId);
1223
+
1224
+ const user = await authService.getUserById(db, userId);
1225
+ if (user) {
1226
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
1227
+
1228
+ return c.json({
1229
+ user: authService.sanitizeUser(user),
1230
+ accessToken: session.accessToken,
1231
+ refreshToken: session.refreshToken,
1232
+ }, 201);
1233
+ }
1234
+
1235
+ return c.json({ accessToken: session.accessToken, refreshToken: session.refreshToken }, 201);
1236
+ } catch (err) {
1237
+ if (err instanceof EdgeBaseError) throw err;
1238
+ await deleteAnon(db, userId).catch(() => {});
1239
+ throw new EdgeBaseError(500, 'Anonymous signin failed.', undefined, 'internal-error');
1240
+ }
1241
+ });
1242
+
1243
+ // ─── Magic Link (D1 Control Plane) ───
1244
+
1245
+ const signinMagicLink = createRoute({
1246
+ operationId: 'authSigninMagicLink',
1247
+ method: 'post',
1248
+ path: '/signin/magic-link',
1249
+ tags: ['client'],
1250
+ summary: 'Send magic link to email',
1251
+ request: {
1252
+ body: { content: { 'application/json': { schema: z.object({
1253
+ email: z.string(),
1254
+ }).passthrough() } }, required: true },
1255
+ },
1256
+ responses: {
1257
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1258
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1259
+ 404: { description: 'Magic link not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
1260
+ },
1261
+ });
1262
+
1263
+ authRoute.openapi(signinMagicLink, async (c) => {
1264
+ const body = await c.req.json<{
1265
+ email: string;
1266
+ redirectUrl?: string;
1267
+ state?: string;
1268
+ }>();
1269
+ if (!body.email) {
1270
+ throw new EdgeBaseError(400, 'Email is required.', undefined, 'invalid-input');
1271
+ }
1272
+ body.email = body.email.trim().toLowerCase();
1273
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
1274
+ throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
1275
+ }
1276
+ const redirect = parseClientRedirectInput(c.env, body);
1277
+
1278
+ await ensureAuthActionAllowed(c, 'signInMagicLink', body as unknown as Record<string, unknown>);
1279
+
1280
+ const config = c.env ? parseConfig(c.env) : undefined;
1281
+ if (!config?.auth?.magicLink?.enabled) {
1282
+ throw new EdgeBaseError(404, 'Magic link authentication is not enabled.', undefined, 'feature-not-enabled');
1283
+ }
1284
+
1285
+ const autoCreate = config.auth.magicLink.autoCreate !== false; // default true
1286
+
1287
+ // Look up email in D1
1288
+ const record = await lookupEmail(getAuthDb(c), body.email);
1289
+
1290
+ const db = getAuthDb(c);
1291
+ let debugToken: string | undefined;
1292
+ let debugActionUrl: string | undefined;
1293
+
1294
+ if (record) {
1295
+ // Existing user — send magic link directly via D1
1296
+ const { userId } = record;
1297
+ const user = await authService.getUserById(db, userId);
1298
+ if (!user) return c.json({ ok: true }); // Don't reveal details
1299
+ if (!user.email) return c.json({ ok: true });
1300
+
1301
+ // Delete old magic-link tokens
1302
+ await authService.deleteEmailTokensByUserAndType(db, userId, 'magic-link');
1303
+
1304
+ const magicLinkConfig = getMagicLinkConfig(c.env);
1305
+ const tokenTTL = magicLinkConfig?.tokenTTL ?? '15m';
1306
+ const ttlMs = parseTTLtoMs(tokenTTL);
1307
+
1308
+ const token = crypto.randomUUID();
1309
+ const now = new Date();
1310
+ const expiresAt = new Date(now.getTime() + ttlMs);
1311
+ await authService.createEmailToken(db, {
1312
+ token,
1313
+ userId,
1314
+ type: 'magic-link',
1315
+ expiresAt: expiresAt.toISOString(),
1316
+ });
1317
+
1318
+ const provider = createEmailProvider(getEmailConfig(c.env), c.env);
1319
+ const emailCfg = getEmailConfig(c.env);
1320
+ const fallbackMagicLinkUrl = emailCfg?.magicLinkUrl
1321
+ ? emailCfg.magicLinkUrl.replace('{token}', token)
1322
+ : `#magic-link?token=${token}`;
1323
+ const magicLinkUrl = buildEmailActionUrl({
1324
+ redirectUrl: redirect.redirectUrl,
1325
+ fallbackUrl: fallbackMagicLinkUrl,
1326
+ token,
1327
+ type: 'magic-link',
1328
+ state: redirect.state,
1329
+ });
1330
+ if (!provider) {
1331
+ const release = config?.release ?? false;
1332
+ if (!release) {
1333
+ console.warn('[MagicLink] Email provider not configured. Token:', token);
1334
+ debugToken = token;
1335
+ debugActionUrl = magicLinkUrl;
1336
+ }
1337
+ } else {
1338
+ const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
1339
+ const html = renderMagicLink({
1340
+ appName: getAppName(c.env),
1341
+ magicLinkUrl,
1342
+ expiresInMinutes: Math.round(ttlMs / 60000),
1343
+ }, resolveLocalizedString(getEmailTemplates(c.env)?.magicLink, locale), locale);
1344
+
1345
+ const defaultSubject = getDefaultSubject(locale, 'magicLink').replace(/\{\{appName\}\}/g, getAppName(c.env));
1346
+ await sendMailWithHook(
1347
+ c.env, c.executionCtx, provider, 'magicLink', user.email as string,
1348
+ resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
1349
+ );
1350
+ }
1351
+ } else if (autoCreate) {
1352
+ // Auto-create user + send magic link
1353
+ const userId = generateId();
1354
+
1355
+ try {
1356
+ await registerEmailPending(db, body.email, userId);
1357
+ } catch (err) {
1358
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
1359
+ return c.json({ ok: true });
1360
+ }
1361
+ throw new EdgeBaseError(500, 'Magic link request failed.', undefined, 'internal-error');
1362
+ }
1363
+
1364
+ try {
1365
+ // Create user with no password, verified = 1
1366
+ const reqLocale = parseAcceptLanguage(c.req.header('accept-language'));
1367
+ await authService.createUser(db, {
1368
+ userId,
1369
+ email: body.email,
1370
+ passwordHash: '',
1371
+ emailVisibility: 'private',
1372
+ role: 'user',
1373
+ verified: true,
1374
+ locale: reqLocale ?? 'en',
1375
+ });
1376
+
1377
+ // beforeSignUp hook
1378
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1379
+ id: userId, email: body.email, displayName: null, avatarUrl: null,
1380
+ }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1381
+
1382
+ // Sync to _users_public
1383
+ const user = await authService.getUserById(db, userId);
1384
+ if (user) {
1385
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
1386
+ c.executionCtx.waitUntil(
1387
+ executeAuthHook(c.env, c.executionCtx, 'afterSignUp', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
1388
+ );
1389
+ }
1390
+
1391
+ await confirmEmail(db, body.email, userId);
1392
+
1393
+ // Send magic link token
1394
+ const magicLinkConfig = getMagicLinkConfig(c.env);
1395
+ const tokenTTL = magicLinkConfig?.tokenTTL ?? '15m';
1396
+ const ttlMs = parseTTLtoMs(tokenTTL);
1397
+
1398
+ const token = crypto.randomUUID();
1399
+ const tokenNow = new Date();
1400
+ const expiresAt = new Date(tokenNow.getTime() + ttlMs);
1401
+ await authService.createEmailToken(db, {
1402
+ token,
1403
+ userId,
1404
+ type: 'magic-link',
1405
+ expiresAt: expiresAt.toISOString(),
1406
+ });
1407
+
1408
+ const provider = createEmailProvider(getEmailConfig(c.env), c.env);
1409
+ const emailCfg = getEmailConfig(c.env);
1410
+ const fallbackMagicLinkUrl = emailCfg?.magicLinkUrl
1411
+ ? emailCfg.magicLinkUrl.replace('{token}', token)
1412
+ : `#magic-link?token=${token}`;
1413
+ const magicLinkUrl = buildEmailActionUrl({
1414
+ redirectUrl: redirect.redirectUrl,
1415
+ fallbackUrl: fallbackMagicLinkUrl,
1416
+ token,
1417
+ type: 'magic-link',
1418
+ state: redirect.state,
1419
+ });
1420
+ if (provider) {
1421
+ const locale = resolveEmailLocale(c.env, reqLocale);
1422
+ const html = renderMagicLink({
1423
+ appName: getAppName(c.env),
1424
+ magicLinkUrl,
1425
+ expiresInMinutes: Math.round(ttlMs / 60000),
1426
+ }, resolveLocalizedString(getEmailTemplates(c.env)?.magicLink, locale), locale);
1427
+
1428
+ const defaultSubject = getDefaultSubject(locale, 'magicLink').replace(/\{\{appName\}\}/g, getAppName(c.env));
1429
+ await sendMailWithHook(
1430
+ c.env, c.executionCtx, provider, 'magicLink', body.email,
1431
+ resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
1432
+ ).catch(() => {});
1433
+ } else {
1434
+ const release = config?.release ?? false;
1435
+ if (!release) {
1436
+ debugToken = token;
1437
+ debugActionUrl = magicLinkUrl;
1438
+ }
1439
+ }
1440
+ } catch (err) {
1441
+ if (err instanceof EdgeBaseError) throw err;
1442
+ await deleteEmailPending(db, body.email).catch(() => {});
1443
+ return c.json({ ok: true });
1444
+ }
1445
+ }
1446
+ // else: !autoCreate && !record → return ok (don't reveal email existence)
1447
+
1448
+ return c.json(debugToken
1449
+ ? { ok: true, token: debugToken, actionUrl: debugActionUrl }
1450
+ : { ok: true });
1451
+ });
1452
+
1453
+ const verifyMagicLink = createRoute({
1454
+ operationId: 'authVerifyMagicLink',
1455
+ method: 'post',
1456
+ path: '/verify-magic-link',
1457
+ tags: ['client'],
1458
+ summary: 'Verify magic link token',
1459
+ request: {
1460
+ body: { content: { 'application/json': { schema: z.object({
1461
+ token: z.string(),
1462
+ }).passthrough() } }, required: true },
1463
+ },
1464
+ responses: {
1465
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1466
+ 400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
1467
+ },
1468
+ });
1469
+
1470
+ authRoute.openapi(verifyMagicLink, async (c) => {
1471
+ const body = await c.req.json<{ token: string }>();
1472
+ if (!body.token) throw new EdgeBaseError(400, 'Magic link token is required.', undefined, 'invalid-input');
1473
+
1474
+ await ensureAuthActionAllowed(c, 'verifyMagicLink', body as unknown as Record<string, unknown>);
1475
+
1476
+ const db = getAuthDb(c);
1477
+ const ip = getClientIP(c.env, c.req.raw);
1478
+
1479
+ // Look up token directly in D1
1480
+ const tokenRow = await authService.getEmailToken(db, body.token);
1481
+ if (!tokenRow || tokenRow.type !== 'magic-link') {
1482
+ throw new EdgeBaseError(400, 'Invalid or expired magic link token.', undefined, 'invalid-token');
1483
+ }
1484
+
1485
+ if (new Date(tokenRow.expiresAt as string) < new Date()) {
1486
+ await authService.deleteEmailToken(db, body.token);
1487
+ throw new EdgeBaseError(400, 'Magic link has expired. Please request a new one.', undefined, 'token-expired');
1488
+ }
1489
+
1490
+ const userId = tokenRow.userId as string;
1491
+ const user = await authService.getUserById(db, userId);
1492
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
1493
+
1494
+ // Disabled user check
1495
+ if (user.disabled === 1) {
1496
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
1497
+ }
1498
+
1499
+ // Mark email as verified if not already
1500
+ if (!user.verified) {
1501
+ await authService.updateUser(db, userId, { verified: true });
1502
+ }
1503
+
1504
+ // beforeSignIn hook
1505
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1506
+
1507
+ // Delete the token (single-use)
1508
+ await authService.deleteEmailToken(db, body.token);
1509
+
1510
+ // Lazy cleanup of expired sessions
1511
+ await authService.cleanExpiredSessionsForUser(db, userId);
1512
+
1513
+ // Create session
1514
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
1515
+
1516
+ // Re-read user (verified flag may have been updated)
1517
+ const updatedUser = await authService.getUserById(db, userId) || user;
1518
+
1519
+ // afterSignIn hook — non-blocking
1520
+ c.executionCtx.waitUntil(
1521
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(updatedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
1522
+ );
1523
+
1524
+ return c.json({
1525
+ user: authService.sanitizeUser(updatedUser),
1526
+ accessToken: session.accessToken,
1527
+ refreshToken: session.refreshToken,
1528
+ });
1529
+ });
1530
+
1531
+ // ─── Phone/SMS OTP Routes ───
1532
+
1533
+ /**
1534
+ * E.164 phone number normalization.
1535
+ * Strips whitespace, dashes, parentheses. Must start with '+'.
1536
+ */
1537
+ function normalizePhone(phone: string): string {
1538
+ const cleaned = phone.replace(/[\s\-()]/g, '');
1539
+ if (!/^\+[1-9]\d{6,14}$/.test(cleaned)) {
1540
+ throw new EdgeBaseError(400, 'Invalid phone number. Must be in E.164 format (e.g. +15551234567).', undefined, 'invalid-phone');
1541
+ }
1542
+ return cleaned;
1543
+ }
1544
+
1545
+ function getPhoneAuthEnabled(env: Env): boolean {
1546
+ try {
1547
+ const config = parseConfig(env);
1548
+ return !!config?.auth?.phoneAuth;
1549
+ } catch {
1550
+ return false;
1551
+ }
1552
+ }
1553
+
1554
+ // POST /signin/phone — send OTP SMS
1555
+ const signinPhone = createRoute({
1556
+ operationId: 'authSigninPhone',
1557
+ method: 'post',
1558
+ path: '/signin/phone',
1559
+ tags: ['client'],
1560
+ summary: 'Send OTP SMS to phone number',
1561
+ request: {
1562
+ body: { content: { 'application/json': { schema: z.object({
1563
+ phone: z.string(),
1564
+ }).passthrough() } }, required: true },
1565
+ },
1566
+ responses: {
1567
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1568
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1569
+ 404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
1570
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
1571
+ },
1572
+ });
1573
+
1574
+ authRoute.openapi(signinPhone, async (c) => {
1575
+ if (!getPhoneAuthEnabled(c.env)) {
1576
+ throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
1577
+ }
1578
+
1579
+ const body = await c.req.json<{ phone: string }>();
1580
+ if (!body.phone) throw new EdgeBaseError(400, 'Phone number is required.', undefined, 'invalid-input');
1581
+ const phone = normalizePhone(body.phone);
1582
+
1583
+ await ensureAuthActionAllowed(c, 'signInPhone', { phone });
1584
+
1585
+ // Rate limit per phone: max 5 OTPs per hour
1586
+ const phoneRateKey = `phone-rate:${phone}`;
1587
+ if (!counter.check(phoneRateKey, 5, 3600)) {
1588
+ throw new EdgeBaseError(429, 'Too many OTP requests for this phone number. Try again later.', undefined, 'rate-limited');
1589
+ }
1590
+
1591
+ // Look up phone in D1
1592
+ const record = await lookupPhone(getAuthDb(c), phone);
1593
+
1594
+ let devCode: string | undefined;
1595
+
1596
+ const db = getAuthDb(c);
1597
+
1598
+ if (record) {
1599
+ // Existing user — send OTP directly
1600
+ const { userId } = record;
1601
+ const user = await authService.getUserById(db, userId);
1602
+ if (!user) return c.json({ ok: true });
1603
+
1604
+ const code = generateOTP();
1605
+
1606
+ // Store OTP in KV with 5 min TTL
1607
+ await c.env.KV.put(
1608
+ `phone-otp:${phone}`,
1609
+ JSON.stringify({ code, userId, attempts: 0 }),
1610
+ { expirationTtl: 300 },
1611
+ );
1612
+
1613
+ // Send SMS
1614
+ const smsProvider = createSmsProvider(getSmsConfig(c.env), c.env);
1615
+ if (smsProvider) {
1616
+ const appName = getAppName(c.env);
1617
+ await sendSmsWithHook(
1618
+ c.env,
1619
+ c.executionCtx,
1620
+ smsProvider,
1621
+ 'phoneOtp',
1622
+ phone,
1623
+ `Your ${appName} verification code is: ${code}. Valid for 5 minutes.`,
1624
+ );
1625
+ } else {
1626
+ const release = parseConfig(c.env)?.release ?? false;
1627
+ if (!release) {
1628
+ console.warn('[Phone] SMS provider not configured. OTP:', code);
1629
+ devCode = code;
1630
+ }
1631
+ }
1632
+ } else {
1633
+ // New user — auto-create
1634
+ const userId = generateId();
1635
+
1636
+ try {
1637
+ await registerPhonePending(db, phone, userId);
1638
+ } catch (err) {
1639
+ if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
1640
+ return c.json({ ok: true });
1641
+ }
1642
+ throw new EdgeBaseError(500, 'Phone OTP request failed.', undefined, 'internal-error');
1643
+ }
1644
+
1645
+ try {
1646
+ // Create user with phone in D1
1647
+ await authService.createUser(db, {
1648
+ userId,
1649
+ email: null,
1650
+ passwordHash: '',
1651
+ role: 'user',
1652
+ verified: true,
1653
+ });
1654
+ await authService.updateUser(db, userId, { phone, phoneVerified: false });
1655
+
1656
+ const code = generateOTP();
1657
+
1658
+ // Store OTP in KV
1659
+ await c.env.KV.put(
1660
+ `phone-otp:${phone}`,
1661
+ JSON.stringify({ code, userId, attempts: 0 }),
1662
+ { expirationTtl: 300 },
1663
+ );
1664
+
1665
+ // Send SMS
1666
+ const smsProvider = createSmsProvider(getSmsConfig(c.env), c.env);
1667
+ if (smsProvider) {
1668
+ const appName = getAppName(c.env);
1669
+ await sendSmsWithHook(
1670
+ c.env,
1671
+ c.executionCtx,
1672
+ smsProvider,
1673
+ 'phoneOtp',
1674
+ phone,
1675
+ `Your ${appName} verification code is: ${code}. Valid for 5 minutes.`,
1676
+ );
1677
+ } else {
1678
+ const release = parseConfig(c.env)?.release ?? false;
1679
+ if (!release) {
1680
+ console.warn('[Phone] SMS provider not configured. OTP:', code);
1681
+ devCode = code;
1682
+ }
1683
+ }
1684
+
1685
+ await confirmPhone(db, phone, userId);
1686
+ } catch (err) {
1687
+ if (err instanceof EdgeBaseError) throw err;
1688
+ return c.json({ ok: true });
1689
+ }
1690
+ }
1691
+
1692
+ // Return OTP code only in dev mode (SMS provider not configured) for testing
1693
+ const release = parseConfig(c.env)?.release ?? false;
1694
+ return c.json(devCode && !release ? { ok: true, code: devCode } : { ok: true });
1695
+ });
1696
+
1697
+ // POST /verify-phone — verify OTP → create session
1698
+ const verifyPhone = createRoute({
1699
+ operationId: 'authVerifyPhone',
1700
+ method: 'post',
1701
+ path: '/verify-phone',
1702
+ tags: ['client'],
1703
+ summary: 'Verify phone OTP and create session',
1704
+ request: {
1705
+ body: { content: { 'application/json': { schema: z.object({
1706
+ phone: z.string(),
1707
+ code: z.string(),
1708
+ }).passthrough() } }, required: true },
1709
+ },
1710
+ responses: {
1711
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1712
+ 400: { description: 'Invalid or expired OTP', content: { 'application/json': { schema: errorResponseSchema } } },
1713
+ 401: { description: 'Invalid OTP code', content: { 'application/json': { schema: errorResponseSchema } } },
1714
+ 404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
1715
+ 429: { description: 'Too many attempts', content: { 'application/json': { schema: errorResponseSchema } } },
1716
+ },
1717
+ });
1718
+
1719
+ authRoute.openapi(verifyPhone, async (c) => {
1720
+ if (!getPhoneAuthEnabled(c.env)) {
1721
+ throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
1722
+ }
1723
+
1724
+ const body = await c.req.json<{ phone: string; code: string }>();
1725
+ if (!body.phone || !body.code) {
1726
+ throw new EdgeBaseError(400, 'Phone number and OTP code are required.', undefined, 'invalid-input');
1727
+ }
1728
+ const phone = normalizePhone(body.phone);
1729
+
1730
+ await ensureAuthActionAllowed(c, 'verifyPhoneOtp', {
1731
+ phone,
1732
+ code: body.code,
1733
+ });
1734
+
1735
+ // Look up phone → userId via KV OTP data
1736
+ const otpData = await c.env.KV.get(`phone-otp:${phone}`, 'json') as {
1737
+ code: string; userId: string; attempts: number;
1738
+ } | null;
1739
+
1740
+ if (!otpData) {
1741
+ throw new EdgeBaseError(400, 'Invalid or expired OTP. Please request a new code.', undefined, 'invalid-token');
1742
+ }
1743
+
1744
+ // Check attempts (max 5)
1745
+ if (otpData.attempts >= 5) {
1746
+ await c.env.KV.delete(`phone-otp:${phone}`).catch(() => {});
1747
+ throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
1748
+ }
1749
+
1750
+ // Verify code (timing-safe comparison)
1751
+ if (!timingSafeEqual(otpData.code, body.code)) {
1752
+ await c.env.KV.put(
1753
+ `phone-otp:${phone}`,
1754
+ JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
1755
+ { expirationTtl: 300 },
1756
+ ).catch(() => {});
1757
+ throw new EdgeBaseError(401, 'Invalid OTP code.', undefined, 'invalid-otp');
1758
+ }
1759
+
1760
+ // OTP valid — delete it (single-use)
1761
+ await c.env.KV.delete(`phone-otp:${phone}`).catch(() => {});
1762
+
1763
+ const { userId } = otpData;
1764
+ const ip = getClientIP(c.env, c.req.raw);
1765
+ const db = getAuthDb(c);
1766
+
1767
+ const user = await authService.getUserById(db, userId);
1768
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
1769
+
1770
+ // Disabled user check
1771
+ if (user.disabled === 1) {
1772
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
1773
+ }
1774
+
1775
+ // Mark phone as verified if not already
1776
+ if (user.phoneVerified !== 1) {
1777
+ await authService.updateUser(db, userId, { phoneVerified: true });
1778
+ }
1779
+
1780
+ // beforeSignIn hook
1781
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1782
+
1783
+ // MFA Check
1784
+ const mfaConfig = getMfaConfig(c.env);
1785
+ if (mfaConfig?.totp) {
1786
+ const factors = await authService.listVerifiedMfaFactors(db, userId);
1787
+ if (factors.length > 0) {
1788
+ const mfaTicket = crypto.randomUUID();
1789
+ await c.env.KV.put(
1790
+ `mfa-ticket:${mfaTicket}`,
1791
+ JSON.stringify({ userId }),
1792
+ { expirationTtl: 300 },
1793
+ );
1794
+ return c.json({
1795
+ mfaRequired: true,
1796
+ mfaTicket,
1797
+ factors: factors.map((f: Record<string, unknown>) => ({ id: f.id, type: f.type })),
1798
+ });
1799
+ }
1800
+ }
1801
+
1802
+ // Create session
1803
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
1804
+
1805
+ // Re-fetch user after phoneVerified update
1806
+ const updatedUser = await authService.getUserById(db, userId) || user;
1807
+
1808
+ // afterSignIn hook — non-blocking
1809
+ c.executionCtx.waitUntil(
1810
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(updatedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
1811
+ );
1812
+
1813
+ return c.json({
1814
+ user: authService.sanitizeUser(updatedUser),
1815
+ accessToken: session.accessToken,
1816
+ refreshToken: session.refreshToken,
1817
+ });
1818
+ });
1819
+
1820
+ // POST /link/phone — link phone to existing account (authenticated)
1821
+ const linkPhone = createRoute({
1822
+ operationId: 'authLinkPhone',
1823
+ method: 'post',
1824
+ path: '/link/phone',
1825
+ tags: ['client'],
1826
+ summary: 'Link phone number to existing account',
1827
+ request: {
1828
+ body: { content: { 'application/json': { schema: z.object({
1829
+ phone: z.string(),
1830
+ }).passthrough() } }, required: true },
1831
+ },
1832
+ responses: {
1833
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1834
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
1835
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
1836
+ 404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
1837
+ 409: { description: 'Phone already registered', content: { 'application/json': { schema: errorResponseSchema } } },
1838
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
1839
+ },
1840
+ });
1841
+
1842
+ authRoute.openapi(linkPhone, async (c) => {
1843
+ if (!getPhoneAuthEnabled(c.env)) {
1844
+ throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
1845
+ }
1846
+
1847
+ const userId = requireAuth(c.get('auth'));
1848
+ const body = await c.req.json<{ phone: string }>();
1849
+ if (!body.phone) throw new EdgeBaseError(400, 'Phone number is required.', undefined, 'invalid-input');
1850
+ const phone = normalizePhone(body.phone);
1851
+ await ensureAuthActionAllowed(c, 'linkPhone', { phone, userId });
1852
+ const db = getAuthDb(c);
1853
+
1854
+ // Rate limit per phone
1855
+ const phoneRateKey = `phone-rate:${phone}`;
1856
+ if (!counter.check(phoneRateKey, 5, 3600)) {
1857
+ throw new EdgeBaseError(429, 'Too many OTP requests. Try again later.', undefined, 'rate-limited');
1858
+ }
1859
+
1860
+ // Check if phone is already registered
1861
+ const existing = await lookupPhone(db, phone);
1862
+ if (existing) {
1863
+ throw new EdgeBaseError(409, 'Phone number is already registered to another account.', undefined, 'phone-already-exists');
1864
+ }
1865
+
1866
+ // Check if user already has a phone
1867
+ const user = await authService.getUserById(db, userId);
1868
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
1869
+ if (user.phone) {
1870
+ throw new EdgeBaseError(409, 'User already has a phone number linked.', undefined, 'already-exists');
1871
+ }
1872
+
1873
+ const code = generateOTP();
1874
+
1875
+ // Store link OTP in KV (separate key pattern)
1876
+ await c.env.KV.put(
1877
+ `phone-link-otp:${phone}`,
1878
+ JSON.stringify({ code, userId, attempts: 0 }),
1879
+ { expirationTtl: 300 },
1880
+ );
1881
+
1882
+ // Send SMS
1883
+ const smsProvider = createSmsProvider(getSmsConfig(c.env), c.env);
1884
+ if (smsProvider) {
1885
+ const appName = getAppName(c.env);
1886
+ await sendSmsWithHook(
1887
+ c.env,
1888
+ c.executionCtx,
1889
+ smsProvider,
1890
+ 'phoneLink',
1891
+ phone,
1892
+ `Your ${appName} phone linking code is: ${code}. Valid for 5 minutes.`,
1893
+ );
1894
+ return c.json({ ok: true });
1895
+ } else {
1896
+ const release = parseConfig(c.env)?.release ?? false;
1897
+ if (!release) {
1898
+ console.warn('[Phone] SMS provider not configured. Link OTP:', code);
1899
+ return c.json({ ok: true, code });
1900
+ }
1901
+ return c.json({ ok: true });
1902
+ }
1903
+ });
1904
+
1905
+ // POST /verify-link-phone — verify OTP and link phone to account (authenticated)
1906
+ const verifyLinkPhone = createRoute({
1907
+ operationId: 'authVerifyLinkPhone',
1908
+ method: 'post',
1909
+ path: '/verify-link-phone',
1910
+ tags: ['client'],
1911
+ summary: 'Verify OTP and link phone to account',
1912
+ request: {
1913
+ body: { content: { 'application/json': { schema: z.object({
1914
+ phone: z.string(),
1915
+ code: z.string(),
1916
+ }).passthrough() } }, required: true },
1917
+ },
1918
+ responses: {
1919
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
1920
+ 400: { description: 'Invalid or expired OTP', content: { 'application/json': { schema: errorResponseSchema } } },
1921
+ 401: { description: 'Invalid OTP code', content: { 'application/json': { schema: errorResponseSchema } } },
1922
+ 403: { description: 'OTP not issued for this user', content: { 'application/json': { schema: errorResponseSchema } } },
1923
+ 404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
1924
+ 409: { description: 'Phone already registered', content: { 'application/json': { schema: errorResponseSchema } } },
1925
+ 429: { description: 'Too many attempts', content: { 'application/json': { schema: errorResponseSchema } } },
1926
+ },
1927
+ });
1928
+
1929
+ authRoute.openapi(verifyLinkPhone, async (c) => {
1930
+ if (!getPhoneAuthEnabled(c.env)) {
1931
+ throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
1932
+ }
1933
+
1934
+ const userId = requireAuth(c.get('auth'));
1935
+ const body = await c.req.json<{ phone: string; code: string }>();
1936
+ if (!body.phone || !body.code) {
1937
+ throw new EdgeBaseError(400, 'Phone number and OTP code are required.', undefined, 'invalid-input');
1938
+ }
1939
+ const phone = normalizePhone(body.phone);
1940
+ await ensureAuthActionAllowed(c, 'verifyLinkPhone', { phone, code: body.code, userId });
1941
+ const db = getAuthDb(c);
1942
+
1943
+ // Verify OTP from KV
1944
+ const otpData = await c.env.KV.get(`phone-link-otp:${phone}`, 'json') as {
1945
+ code: string; userId: string; attempts: number;
1946
+ } | null;
1947
+
1948
+ if (!otpData) {
1949
+ throw new EdgeBaseError(400, 'Invalid or expired OTP. Please request a new code.', undefined, 'invalid-token');
1950
+ }
1951
+
1952
+ if (otpData.userId !== userId) {
1953
+ throw new EdgeBaseError(403, 'OTP was not issued for this user.', undefined, 'action-not-allowed');
1954
+ }
1955
+
1956
+ if (otpData.attempts >= 5) {
1957
+ await c.env.KV.delete(`phone-link-otp:${phone}`).catch(() => {});
1958
+ throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
1959
+ }
1960
+
1961
+ if (!timingSafeEqual(otpData.code, body.code)) {
1962
+ await c.env.KV.put(
1963
+ `phone-link-otp:${phone}`,
1964
+ JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
1965
+ { expirationTtl: 300 },
1966
+ ).catch(() => {});
1967
+ throw new EdgeBaseError(401, 'Invalid OTP code.', undefined, 'invalid-otp');
1968
+ }
1969
+
1970
+ // OTP valid — delete it
1971
+ await c.env.KV.delete(`phone-link-otp:${phone}`).catch(() => {});
1972
+
1973
+ // Register phone in D1
1974
+ try {
1975
+ await registerPhonePending(db, phone, userId);
1976
+ } catch (err) {
1977
+ if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
1978
+ throw new EdgeBaseError(409, 'Phone number is already registered.', undefined, 'phone-already-exists');
1979
+ }
1980
+ throw new EdgeBaseError(500, 'Phone linking failed.', undefined, 'internal-error');
1981
+ }
1982
+
1983
+ // Update user record directly in D1
1984
+ await authService.updateUser(db, userId, { phone, phoneVerified: true, isAnonymous: false });
1985
+
1986
+ // Confirm in D1
1987
+ await confirmPhone(db, phone, userId);
1988
+
1989
+ // Delete anon index if exists (upgrade path)
1990
+ await deleteAnon(db, userId).catch(() => {});
1991
+
1992
+ return c.json({ ok: true });
1993
+ });
1994
+
1995
+ // ─── Email OTP Routes ───
1996
+
1997
+ function getEmailOtpEnabled(env: Env): boolean {
1998
+ try {
1999
+ const config = parseConfig(env);
2000
+ return !!config?.auth?.emailOtp?.enabled;
2001
+ } catch {
2002
+ return false;
2003
+ }
2004
+ }
2005
+
2006
+ // POST /signin/email-otp — send OTP code to email
2007
+ const signinEmailOtp = createRoute({
2008
+ operationId: 'authSigninEmailOtp',
2009
+ method: 'post',
2010
+ path: '/signin/email-otp',
2011
+ tags: ['client'],
2012
+ summary: 'Send OTP code to email',
2013
+ request: {
2014
+ body: { content: { 'application/json': { schema: z.object({
2015
+ email: z.string(),
2016
+ }).passthrough() } }, required: true },
2017
+ },
2018
+ responses: {
2019
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2020
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2021
+ 404: { description: 'Email OTP not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
2022
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
2023
+ },
2024
+ });
2025
+
2026
+ authRoute.openapi(signinEmailOtp, async (c) => {
2027
+ if (!getEmailOtpEnabled(c.env)) {
2028
+ throw new EdgeBaseError(404, 'Email OTP authentication is not enabled.', undefined, 'feature-not-enabled');
2029
+ }
2030
+
2031
+ const body = await c.req.json<{ email: string }>();
2032
+ if (!body.email) throw new EdgeBaseError(400, 'Email is required.', undefined, 'invalid-input');
2033
+ const email = body.email.trim().toLowerCase();
2034
+
2035
+ // Basic email validation
2036
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
2037
+ throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
2038
+ }
2039
+
2040
+ await ensureAuthActionAllowed(c, 'signInEmailOtp', { email });
2041
+
2042
+ // Rate limit per email: max 5 OTPs per hour
2043
+ const emailRateKey = `email-otp-rate:${email}`;
2044
+ if (!counter.check(emailRateKey, 5, 3600)) {
2045
+ throw new EdgeBaseError(429, 'Too many OTP requests for this email. Try again later.', undefined, 'rate-limited');
2046
+ }
2047
+
2048
+ // Look up email in D1
2049
+ const record = await lookupEmail(getAuthDb(c), email);
2050
+
2051
+ let devCode: string | undefined;
2052
+
2053
+ const db = getAuthDb(c);
2054
+
2055
+ if (record) {
2056
+ // Existing user — send OTP directly
2057
+ const { userId } = record;
2058
+ const user = await authService.getUserById(db, userId);
2059
+ if (!user) return c.json({ ok: true });
2060
+
2061
+ const code = generateOTP();
2062
+
2063
+ // Store OTP in KV with 5 min TTL
2064
+ await c.env.KV.put(
2065
+ `email-otp:${email}`,
2066
+ JSON.stringify({ code, userId, attempts: 0 }),
2067
+ { expirationTtl: 300 },
2068
+ );
2069
+
2070
+ // Send email
2071
+ const emailProvider = createEmailProvider(getEmailConfig(c.env), c.env);
2072
+ if (emailProvider) {
2073
+ const appName = getAppName(c.env);
2074
+ const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
2075
+ const html = renderEmailOtp({ appName, code, expiresInMinutes: 5 }, resolveLocalizedString(getEmailTemplates(c.env)?.emailOtp, locale), locale);
2076
+ const defaultSubject = getDefaultSubject(locale, 'emailOtp').replace(/\{\{appName\}\}/g, appName);
2077
+ await sendMailWithHook(
2078
+ c.env, c.executionCtx, emailProvider, 'emailOtp', email,
2079
+ resolveSubject(c.env, 'emailOtp', defaultSubject, locale), html, locale,
2080
+ );
2081
+ } else {
2082
+ const release = parseConfig(c.env)?.release ?? false;
2083
+ if (!release) {
2084
+ console.warn('[EmailOTP] Email provider not configured. OTP:', code);
2085
+ devCode = code;
2086
+ }
2087
+ }
2088
+ } else {
2089
+ // New user — auto-create if enabled
2090
+ const config = parseConfig(c.env);
2091
+ const autoCreate = config?.auth?.emailOtp?.autoCreate !== false;
2092
+ if (!autoCreate) {
2093
+ return c.json({ ok: true });
2094
+ }
2095
+
2096
+ const userId = generateId();
2097
+
2098
+ try {
2099
+ await registerEmailPending(db, email, userId);
2100
+ } catch (err) {
2101
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
2102
+ return c.json({ ok: true });
2103
+ }
2104
+ throw new EdgeBaseError(500, 'Email OTP request failed.', undefined, 'internal-error');
2105
+ }
2106
+
2107
+ try {
2108
+ // Create user with email, verified = 1
2109
+ const otpReqLocale = parseAcceptLanguage(c.req.header('accept-language'));
2110
+ await authService.createUser(db, {
2111
+ userId,
2112
+ email,
2113
+ passwordHash: '',
2114
+ role: 'user',
2115
+ verified: true,
2116
+ locale: otpReqLocale ?? 'en',
2117
+ });
2118
+
2119
+ const code = generateOTP();
2120
+
2121
+ // Store OTP in KV
2122
+ await c.env.KV.put(
2123
+ `email-otp:${email}`,
2124
+ JSON.stringify({ code, userId, attempts: 0 }),
2125
+ { expirationTtl: 300 },
2126
+ );
2127
+
2128
+ // Send email
2129
+ const emailProvider = createEmailProvider(getEmailConfig(c.env), c.env);
2130
+ if (emailProvider) {
2131
+ const appName = getAppName(c.env);
2132
+ const locale = resolveEmailLocale(c.env, otpReqLocale);
2133
+ const html = renderEmailOtp({ appName, code, expiresInMinutes: 5 }, resolveLocalizedString(getEmailTemplates(c.env)?.emailOtp, locale), locale);
2134
+ const defaultSubject = getDefaultSubject(locale, 'emailOtp').replace(/\{\{appName\}\}/g, appName);
2135
+ await sendMailWithHook(
2136
+ c.env, c.executionCtx, emailProvider, 'emailOtp', email,
2137
+ resolveSubject(c.env, 'emailOtp', defaultSubject, locale), html, locale,
2138
+ );
2139
+ } else {
2140
+ const release = parseConfig(c.env)?.release ?? false;
2141
+ if (!release) {
2142
+ console.warn('[EmailOTP] Email provider not configured. OTP:', code);
2143
+ devCode = code;
2144
+ }
2145
+ }
2146
+
2147
+ await confirmEmail(db, email, userId);
2148
+ } catch (err) {
2149
+ if (err instanceof EdgeBaseError) throw err;
2150
+ return c.json({ ok: true });
2151
+ }
2152
+ }
2153
+
2154
+ // Return OTP code only in dev mode (email provider not configured) for testing
2155
+ const release = parseConfig(c.env)?.release ?? false;
2156
+ return c.json(devCode && !release ? { ok: true, code: devCode } : { ok: true });
2157
+ });
2158
+
2159
+ // POST /verify-email-otp — verify OTP → create session
2160
+ const verifyEmailOtp = createRoute({
2161
+ operationId: 'authVerifyEmailOtp',
2162
+ method: 'post',
2163
+ path: '/verify-email-otp',
2164
+ tags: ['client'],
2165
+ summary: 'Verify email OTP and create session',
2166
+ request: {
2167
+ body: { content: { 'application/json': { schema: z.object({
2168
+ email: z.string(),
2169
+ code: z.string(),
2170
+ }).passthrough() } }, required: true },
2171
+ },
2172
+ responses: {
2173
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2174
+ 400: { description: 'Invalid or expired OTP', content: { 'application/json': { schema: errorResponseSchema } } },
2175
+ 401: { description: 'Invalid OTP code', content: { 'application/json': { schema: errorResponseSchema } } },
2176
+ 404: { description: 'Email OTP not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
2177
+ 429: { description: 'Too many attempts', content: { 'application/json': { schema: errorResponseSchema } } },
2178
+ },
2179
+ });
2180
+
2181
+ authRoute.openapi(verifyEmailOtp, async (c) => {
2182
+ if (!getEmailOtpEnabled(c.env)) {
2183
+ throw new EdgeBaseError(404, 'Email OTP authentication is not enabled.', undefined, 'feature-not-enabled');
2184
+ }
2185
+
2186
+ const body = await c.req.json<{ email: string; code: string }>();
2187
+ if (!body.email || !body.code) {
2188
+ throw new EdgeBaseError(400, 'Email and OTP code are required.', undefined, 'invalid-input');
2189
+ }
2190
+ const email = body.email.trim().toLowerCase();
2191
+
2192
+ await ensureAuthActionAllowed(c, 'verifyEmailOtp', {
2193
+ email,
2194
+ code: body.code,
2195
+ });
2196
+
2197
+ // Look up OTP data from KV
2198
+ const otpData = await c.env.KV.get(`email-otp:${email}`, 'json') as {
2199
+ code: string; userId: string; attempts: number;
2200
+ } | null;
2201
+
2202
+ if (!otpData) {
2203
+ throw new EdgeBaseError(400, 'Invalid or expired OTP. Please request a new code.', undefined, 'invalid-token');
2204
+ }
2205
+
2206
+ if (otpData.attempts >= 5) {
2207
+ await c.env.KV.delete(`email-otp:${email}`).catch(() => {});
2208
+ throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
2209
+ }
2210
+
2211
+ if (!timingSafeEqual(otpData.code, body.code)) {
2212
+ await c.env.KV.put(
2213
+ `email-otp:${email}`,
2214
+ JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
2215
+ { expirationTtl: 300 },
2216
+ ).catch(() => {});
2217
+ throw new EdgeBaseError(401, 'Invalid OTP code.', undefined, 'invalid-otp');
2218
+ }
2219
+
2220
+ // OTP valid — delete it (single-use)
2221
+ await c.env.KV.delete(`email-otp:${email}`).catch(() => {});
2222
+
2223
+ const { userId } = otpData;
2224
+ const ip = getClientIP(c.env, c.req.raw);
2225
+ const db = getAuthDb(c);
2226
+
2227
+ const user = await authService.getUserById(db, userId);
2228
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2229
+
2230
+ // Disabled user check
2231
+ if (user.disabled === 1) {
2232
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
2233
+ }
2234
+
2235
+ // beforeSignIn hook
2236
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
2237
+
2238
+ // MFA Check
2239
+ const mfaConfig = getMfaConfig(c.env);
2240
+ if (mfaConfig?.totp) {
2241
+ const factors = await authService.listVerifiedMfaFactors(db, userId);
2242
+ if (factors.length > 0) {
2243
+ const mfaTicket = crypto.randomUUID();
2244
+ await c.env.KV.put(
2245
+ `mfa-ticket:${mfaTicket}`,
2246
+ JSON.stringify({ userId }),
2247
+ { expirationTtl: 300 },
2248
+ );
2249
+ return c.json({
2250
+ mfaRequired: true,
2251
+ mfaTicket,
2252
+ factors: factors.map((f: Record<string, unknown>) => ({ id: f.id, type: f.type })),
2253
+ });
2254
+ }
2255
+ }
2256
+
2257
+ // Create session
2258
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
2259
+
2260
+ // afterSignIn hook — non-blocking
2261
+ c.executionCtx.waitUntil(
2262
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
2263
+ );
2264
+
2265
+ return c.json({
2266
+ user: authService.sanitizeUser(user),
2267
+ accessToken: session.accessToken,
2268
+ refreshToken: session.refreshToken,
2269
+ });
2270
+ });
2271
+
2272
+ // ─── MFA/TOTP Routes ───
2273
+
2274
+ // POST /mfa/totp/enroll — enroll new TOTP factor (authenticated)
2275
+ const mfaTotpEnroll = createRoute({
2276
+ operationId: 'authMfaTotpEnroll',
2277
+ method: 'post',
2278
+ path: '/mfa/totp/enroll',
2279
+ tags: ['client'],
2280
+ summary: 'Enroll new TOTP factor',
2281
+ responses: {
2282
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2283
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
2284
+ },
2285
+ });
2286
+
2287
+ authRoute.openapi(mfaTotpEnroll, async (c) => {
2288
+ const userId = requireAuth(c.get('auth'));
2289
+ await ensureAuthActionAllowed(c, 'mfaTotpEnroll', { userId });
2290
+ const db = getAuthDb(c);
2291
+
2292
+ const mfaCfg = getMfaConfig(c.env);
2293
+ if (!mfaCfg?.totp) throw new EdgeBaseError(404, 'TOTP MFA is not enabled.', undefined, 'feature-not-enabled');
2294
+
2295
+ // Check if user already has a verified TOTP factor
2296
+ const existing = await authService.getMfaFactorByUser(db, userId, 'totp');
2297
+ if (existing && existing.verified) {
2298
+ throw new EdgeBaseError(409, 'TOTP factor already enrolled. Disable it first to re-enroll.', undefined, 'mfa-already-enrolled');
2299
+ }
2300
+
2301
+ // Delete any unverified (pending) factors
2302
+ await authService.deleteUnverifiedMfaFactors(db, userId, 'totp');
2303
+
2304
+ // Generate TOTP secret
2305
+ const secret = generateTOTPSecret();
2306
+ const encryptedSecret = await encryptSecret(secret, getUserSecret(c.env));
2307
+
2308
+ // Get user email for QR code URI
2309
+ const user = await authService.getUserById(db, userId);
2310
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2311
+
2312
+ const appName = getAppName(c.env);
2313
+ const qrCodeUri = generateTOTPUri(secret, (user.email as string) || userId, appName);
2314
+
2315
+ // Create factor (unverified)
2316
+ const factorId = generateId();
2317
+ await authService.createMfaFactor(db, {
2318
+ id: factorId,
2319
+ userId,
2320
+ type: 'totp',
2321
+ secret: encryptedSecret,
2322
+ });
2323
+
2324
+ // Generate recovery codes
2325
+ const recoveryCodes = generateRecoveryCodes(8);
2326
+ const hashedCodes: { id: string; codeHash: string }[] = [];
2327
+ for (const code of recoveryCodes) {
2328
+ hashedCodes.push({ id: generateId(), codeHash: await hashRecoveryCode(code) });
2329
+ }
2330
+ await authService.createRecoveryCodes(db, userId, hashedCodes);
2331
+
2332
+ return c.json({
2333
+ factorId,
2334
+ secret,
2335
+ qrCodeUri,
2336
+ recoveryCodes,
2337
+ });
2338
+ });
2339
+
2340
+ // POST /mfa/totp/verify — confirm TOTP enrollment (authenticated)
2341
+ const mfaTotpVerify = createRoute({
2342
+ operationId: 'authMfaTotpVerify',
2343
+ method: 'post',
2344
+ path: '/mfa/totp/verify',
2345
+ tags: ['client'],
2346
+ summary: 'Confirm TOTP enrollment with code',
2347
+ request: {
2348
+ body: { content: { 'application/json': { schema: z.object({
2349
+ factorId: z.string(),
2350
+ code: z.string(),
2351
+ }).passthrough() } }, required: true },
2352
+ },
2353
+ responses: {
2354
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2355
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2356
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
2357
+ },
2358
+ });
2359
+
2360
+ authRoute.openapi(mfaTotpVerify, async (c) => {
2361
+ const userId = requireAuth(c.get('auth'));
2362
+ const body = await c.req.json<{ factorId: string; code: string }>();
2363
+ if (!body.factorId || !body.code) {
2364
+ throw new EdgeBaseError(400, 'factorId and code are required.', undefined, 'invalid-input');
2365
+ }
2366
+ await ensureAuthActionAllowed(c, 'mfaTotpVerify', {
2367
+ factorId: body.factorId,
2368
+ code: body.code,
2369
+ userId,
2370
+ });
2371
+ const db = getAuthDb(c);
2372
+
2373
+ const factor = await authService.getMfaFactorForUser(db, body.factorId, userId);
2374
+ if (!factor) throw new EdgeBaseError(404, 'TOTP factor not found.', undefined, 'not-found');
2375
+ if (factor.verified) throw new EdgeBaseError(400, 'TOTP factor is already verified.', undefined, 'mfa-already-enrolled');
2376
+
2377
+ // Decrypt and verify TOTP code
2378
+ const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
2379
+ const valid = await verifyTOTP(secret, body.code);
2380
+ if (!valid) throw new EdgeBaseError(400, 'Invalid TOTP code. Please try again.', undefined, 'invalid-totp');
2381
+
2382
+ // Mark factor as verified
2383
+ await authService.verifyMfaFactor(db, body.factorId);
2384
+
2385
+ return c.json({ ok: true });
2386
+ });
2387
+
2388
+ // POST /mfa/verify — verify TOTP code during signin (mfaTicket-based)
2389
+ const mfaVerify = createRoute({
2390
+ operationId: 'authMfaVerify',
2391
+ method: 'post',
2392
+ path: '/mfa/verify',
2393
+ tags: ['client'],
2394
+ summary: 'Verify MFA code during signin',
2395
+ request: {
2396
+ body: { content: { 'application/json': { schema: z.object({
2397
+ mfaTicket: z.string(),
2398
+ code: z.string(),
2399
+ }).passthrough() } }, required: true },
2400
+ },
2401
+ responses: {
2402
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2403
+ 400: { description: 'Invalid or expired MFA ticket', content: { 'application/json': { schema: errorResponseSchema } } },
2404
+ 401: { description: 'MFA verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
2405
+ },
2406
+ });
2407
+
2408
+ authRoute.openapi(mfaVerify, async (c) => {
2409
+ const body = await c.req.json<{ mfaTicket: string; code: string }>();
2410
+ if (!body.mfaTicket || !body.code) {
2411
+ throw new EdgeBaseError(400, 'mfaTicket and code are required.', undefined, 'invalid-input');
2412
+ }
2413
+ await ensureAuthActionAllowed(c, 'mfaVerify', {
2414
+ mfaTicket: body.mfaTicket,
2415
+ code: body.code,
2416
+ });
2417
+
2418
+ // Look up mfaTicket from KV
2419
+ const ticketData = await c.env.KV.get(`mfa-ticket:${body.mfaTicket}`, 'json') as {
2420
+ userId: string;
2421
+ } | null;
2422
+
2423
+ if (!ticketData) {
2424
+ throw new EdgeBaseError(400, 'Invalid or expired MFA ticket.', undefined, 'invalid-token');
2425
+ }
2426
+
2427
+ const { userId } = ticketData;
2428
+ const ip = getClientIP(c.env, c.req.raw);
2429
+ const db = getAuthDb(c);
2430
+
2431
+ // Get verified TOTP factor
2432
+ const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
2433
+ if (!factor || !factor.verified) throw new EdgeBaseError(400, 'No verified TOTP factor found.', undefined, 'invalid-input');
2434
+
2435
+ // Disabled check
2436
+ const user = await authService.getUserById(db, userId);
2437
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2438
+ if (user.disabled === 1) {
2439
+ throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
2440
+ }
2441
+
2442
+ // Decrypt and verify
2443
+ const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
2444
+ const valid = await verifyTOTP(secret, body.code);
2445
+ if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
2446
+
2447
+ // Delete mfaTicket (single-use)
2448
+ await c.env.KV.delete(`mfa-ticket:${body.mfaTicket}`).catch(() => {});
2449
+
2450
+ // MFA passed — create session
2451
+ await authService.cleanExpiredSessionsForUser(db, userId);
2452
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
2453
+
2454
+ // afterSignIn hook — non-blocking
2455
+ c.executionCtx.waitUntil(
2456
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
2457
+ );
2458
+
2459
+ return c.json({
2460
+ user: authService.sanitizeUser(user),
2461
+ accessToken: session.accessToken,
2462
+ refreshToken: session.refreshToken,
2463
+ });
2464
+ });
2465
+
2466
+ // POST /mfa/recovery — use recovery code during signin (mfaTicket-based)
2467
+ const mfaRecovery = createRoute({
2468
+ operationId: 'authMfaRecovery',
2469
+ method: 'post',
2470
+ path: '/mfa/recovery',
2471
+ tags: ['client'],
2472
+ summary: 'Use recovery code during MFA signin',
2473
+ request: {
2474
+ body: { content: { 'application/json': { schema: z.object({
2475
+ mfaTicket: z.string(),
2476
+ recoveryCode: z.string(),
2477
+ }).passthrough() } }, required: true },
2478
+ },
2479
+ responses: {
2480
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2481
+ 400: { description: 'Invalid or expired MFA ticket', content: { 'application/json': { schema: errorResponseSchema } } },
2482
+ 401: { description: 'Recovery code verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
2483
+ },
2484
+ });
2485
+
2486
+ authRoute.openapi(mfaRecovery, async (c) => {
2487
+ const body = await c.req.json<{ mfaTicket: string; recoveryCode: string }>();
2488
+ if (!body.mfaTicket || !body.recoveryCode) {
2489
+ throw new EdgeBaseError(400, 'mfaTicket and recoveryCode are required.', undefined, 'invalid-input');
2490
+ }
2491
+ await ensureAuthActionAllowed(c, 'mfaRecovery', {
2492
+ mfaTicket: body.mfaTicket,
2493
+ recoveryCode: body.recoveryCode,
2494
+ });
2495
+
2496
+ // Look up mfaTicket from KV
2497
+ const ticketData = await c.env.KV.get(`mfa-ticket:${body.mfaTicket}`, 'json') as {
2498
+ userId: string;
2499
+ } | null;
2500
+
2501
+ if (!ticketData) {
2502
+ throw new EdgeBaseError(400, 'Invalid or expired MFA ticket.', undefined, 'invalid-token');
2503
+ }
2504
+
2505
+ const { userId } = ticketData;
2506
+ const ip = getClientIP(c.env, c.req.raw);
2507
+ const db = getAuthDb(c);
2508
+
2509
+ // Disabled check
2510
+ const user = await authService.getUserById(db, userId);
2511
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2512
+ if (user.disabled === 1) {
2513
+ throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
2514
+ }
2515
+
2516
+ // Find unused recovery codes for this user
2517
+ const codes = await authService.listRecoveryCodes(db, userId);
2518
+ if (codes.length === 0) {
2519
+ throw new EdgeBaseError(400, 'No recovery codes available.', undefined, 'invalid-input');
2520
+ }
2521
+
2522
+ // Check each code (hash comparison)
2523
+ let matchedCodeId: string | null = null;
2524
+ for (const codeRow of codes) {
2525
+ const valid = await verifyRecoveryCode(body.recoveryCode, codeRow.codeHash as string);
2526
+ if (valid) {
2527
+ matchedCodeId = codeRow.id as string;
2528
+ break;
2529
+ }
2530
+ }
2531
+
2532
+ if (!matchedCodeId) {
2533
+ throw new EdgeBaseError(401, 'Invalid recovery code.', undefined, 'invalid-recovery-code');
2534
+ }
2535
+
2536
+ // Mark recovery code as used (single-use)
2537
+ await authService.useRecoveryCode(db, matchedCodeId);
2538
+
2539
+ // Delete mfaTicket (single-use)
2540
+ await c.env.KV.delete(`mfa-ticket:${body.mfaTicket}`).catch(() => {});
2541
+
2542
+ // MFA passed — create session
2543
+ await authService.cleanExpiredSessionsForUser(db, userId);
2544
+ const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
2545
+
2546
+ // afterSignIn hook — non-blocking
2547
+ c.executionCtx.waitUntil(
2548
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
2549
+ );
2550
+
2551
+ return c.json({
2552
+ user: authService.sanitizeUser(user),
2553
+ accessToken: session.accessToken,
2554
+ refreshToken: session.refreshToken,
2555
+ });
2556
+ });
2557
+
2558
+ // DELETE /mfa/totp — disable TOTP (authenticated)
2559
+ const mfaTotpDelete = createRoute({
2560
+ operationId: 'authMfaTotpDelete',
2561
+ method: 'delete',
2562
+ path: '/mfa/totp',
2563
+ tags: ['client'],
2564
+ summary: 'Disable TOTP factor',
2565
+ request: {
2566
+ body: { content: { 'application/json': { schema: z.object({
2567
+ password: z.string().optional(),
2568
+ code: z.string().optional(),
2569
+ }).passthrough() } }, required: false },
2570
+ },
2571
+ responses: {
2572
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2573
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
2574
+ },
2575
+ });
2576
+
2577
+ authRoute.openapi(mfaTotpDelete, async (c) => {
2578
+ const userId = requireAuth(c.get('auth'));
2579
+ const bodyText = await c.req.text();
2580
+ let body: { password?: string; code?: string } = {};
2581
+ try { body = JSON.parse(bodyText); } catch { /* empty body OK */ }
2582
+ await ensureAuthActionAllowed(c, 'mfaTotpDelete', {
2583
+ userId,
2584
+ passwordProvided: !!body.password,
2585
+ codeProvided: !!body.code,
2586
+ });
2587
+ const db = getAuthDb(c);
2588
+
2589
+ // Verify identity: require either password or TOTP code
2590
+ const user = await authService.getUserById(db, userId);
2591
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2592
+
2593
+ if (body.password) {
2594
+ if (!user.passwordHash) throw new EdgeBaseError(400, 'This account has no password set.', undefined, 'invalid-input');
2595
+ const valid = await verifyPassword(body.password, user.passwordHash as string);
2596
+ if (!valid) throw new EdgeBaseError(401, 'Invalid password.', undefined, 'invalid-password');
2597
+ } else if (body.code) {
2598
+ const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
2599
+ if (!factor || !factor.verified) throw new EdgeBaseError(400, 'No TOTP factor found.', undefined, 'invalid-input');
2600
+ const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
2601
+ const valid = await verifyTOTP(secret, body.code);
2602
+ if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
2603
+ } else {
2604
+ throw new EdgeBaseError(400, 'Either password or TOTP code is required to disable MFA.', undefined, 'invalid-input');
2605
+ }
2606
+
2607
+ // Delete all MFA factors and recovery codes
2608
+ await authService.disableMfa(db, userId);
2609
+
2610
+ return c.json({ ok: true });
2611
+ });
2612
+
2613
+ // GET /mfa/factors — list user's MFA factors (authenticated)
2614
+ const mfaFactors = createRoute({
2615
+ operationId: 'authMfaFactors',
2616
+ method: 'get',
2617
+ path: '/mfa/factors',
2618
+ tags: ['client'],
2619
+ summary: 'List MFA factors for authenticated user',
2620
+ responses: {
2621
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2622
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
2623
+ },
2624
+ });
2625
+
2626
+ authRoute.openapi(mfaFactors, async (c) => {
2627
+ const userId = requireAuth(c.get('auth'));
2628
+ await ensureAuthActionAllowed(c, 'mfaFactors', { userId });
2629
+ const db = getAuthDb(c);
2630
+
2631
+ const factors = await authService.listMfaFactors(db, userId);
2632
+
2633
+ return c.json({
2634
+ factors: factors.map((f) => ({
2635
+ id: f.id,
2636
+ type: f.type,
2637
+ verified: f.verified,
2638
+ createdAt: f.createdAt,
2639
+ })),
2640
+ });
2641
+ });
2642
+
2643
+ // ─── Shard-routed routes (Refresh Token in body) ───
2644
+
2645
+ const refresh = createRoute({
2646
+ operationId: 'authRefresh',
2647
+ method: 'post',
2648
+ path: '/refresh',
2649
+ tags: ['client'],
2650
+ summary: 'Refresh access token',
2651
+ request: {
2652
+ body: { content: { 'application/json': { schema: z.object({
2653
+ refreshToken: z.string(),
2654
+ }).passthrough() } }, required: true },
2655
+ },
2656
+ responses: {
2657
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2658
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2659
+ 401: { description: 'Invalid refresh token', content: { 'application/json': { schema: errorResponseSchema } } },
2660
+ },
2661
+ });
2662
+
2663
+ authRoute.openapi(refresh, async (c) => {
2664
+ const bodyText = await c.req.text();
2665
+ let body: { refreshToken?: string };
2666
+ try { body = JSON.parse(bodyText); } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
2667
+ if (!body.refreshToken) throw new EdgeBaseError(400, 'Refresh token is required.', undefined, 'invalid-input');
2668
+
2669
+ await ensureAuthActionAllowed(c, 'refresh', {
2670
+ refreshToken: body.refreshToken,
2671
+ });
2672
+
2673
+ const db = getAuthDb(c);
2674
+ const GRACE_PERIOD_SECONDS = 30;
2675
+
2676
+ // Verify the refresh token signature
2677
+ let tokenPayload;
2678
+ try {
2679
+ tokenPayload = await verifyRefreshTokenWithFallback(
2680
+ body.refreshToken,
2681
+ getUserSecret(c.env),
2682
+ c.env.JWT_USER_SECRET_OLD,
2683
+ c.env.JWT_USER_SECRET_OLD_AT,
2684
+ );
2685
+ } catch (err) {
2686
+ if (err instanceof TokenExpiredError) {
2687
+ throw new EdgeBaseError(401, 'Refresh token expired.', undefined, 'refresh-token-expired');
2688
+ }
2689
+ throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
2690
+ }
2691
+
2692
+ const userId = tokenPayload.sub;
2693
+
2694
+ // Use getSessionByRefreshToken which checks both current and previous tokens
2695
+ const result = await authService.getSessionByRefreshToken(db, body.refreshToken, userId);
2696
+
2697
+ if (!result) {
2698
+ throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
2699
+ }
2700
+
2701
+ const { session, matchType } = result;
2702
+
2703
+ if (matchType === 'current') {
2704
+ // Normal rotation
2705
+ return c.json(await rotateRefreshTokenFlow(c.env, c.executionCtx, session, userId, getWorkerUrl(c.req.url, c.env)));
2706
+ }
2707
+
2708
+ // matchType === 'previous' — Grace Period check
2709
+ const rotatedAt = session.rotatedAt as string;
2710
+ const rotatedTime = new Date(rotatedAt).getTime();
2711
+ const gracePeriodMs = GRACE_PERIOD_SECONDS * 1000;
2712
+
2713
+ if (Date.now() - rotatedTime <= gracePeriodMs) {
2714
+ // Within grace period — return current tokens without re-rotation
2715
+ const user = await authService.getUserById(db, userId);
2716
+ if (!user) throw new EdgeBaseError(401, 'User not found.', undefined, 'invalid-credentials');
2717
+ return c.json({
2718
+ user: authService.sanitizeUser(user),
2719
+ accessToken: await generateAccessToken(c.env, user),
2720
+ refreshToken: session.refreshToken as string,
2721
+ });
2722
+ }
2723
+
2724
+ // Beyond grace period — token theft suspected! Revoke session
2725
+ await authService.deleteSession(db, session.id as string);
2726
+ throw new EdgeBaseError(401, 'Refresh token reuse detected. Session revoked.', undefined, 'refresh-token-reused');
2727
+ });
2728
+
2729
+ const signout = createRoute({
2730
+ operationId: 'authSignout',
2731
+ method: 'post',
2732
+ path: '/signout',
2733
+ tags: ['client'],
2734
+ summary: 'Sign out and revoke refresh token',
2735
+ request: {
2736
+ body: { content: { 'application/json': { schema: z.object({
2737
+ refreshToken: z.string(),
2738
+ }).passthrough() } }, required: true },
2739
+ },
2740
+ responses: {
2741
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2742
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2743
+ 401: { description: 'Invalid refresh token', content: { 'application/json': { schema: errorResponseSchema } } },
2744
+ },
2745
+ });
2746
+
2747
+ authRoute.openapi(signout, async (c) => {
2748
+ const bodyText = await c.req.text();
2749
+ let body: { refreshToken?: string };
2750
+ try { body = JSON.parse(bodyText); } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
2751
+ if (!body.refreshToken) throw new EdgeBaseError(400, 'Refresh token is required.', undefined, 'invalid-input');
2752
+
2753
+ await ensureAuthActionAllowed(c, 'signOut', {
2754
+ refreshToken: body.refreshToken,
2755
+ });
2756
+
2757
+ const payload = decodeTokenUnsafe(body.refreshToken);
2758
+ if (!payload?.sub) throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
2759
+
2760
+ const db = getAuthDb(c);
2761
+ const userId = payload.sub as string;
2762
+
2763
+ // beforeSignOut hook — blocking
2764
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignOut', { userId }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
2765
+
2766
+ // Delete session by refreshToken
2767
+ await authService.deleteSessionByRefreshToken(db, body.refreshToken);
2768
+
2769
+ // afterSignOut hook — non-blocking
2770
+ c.executionCtx.waitUntil(
2771
+ executeAuthHook(c.env, c.executionCtx, 'afterSignOut', { userId }, { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
2772
+ );
2773
+
2774
+ return c.json({ ok: true });
2775
+ });
2776
+
2777
+ // ─── Shard-routed routes (Access Token in header) ───
2778
+
2779
+ // POST /change-password — verify current password, set new one
2780
+ const changePassword = createRoute({
2781
+ operationId: 'authChangePassword',
2782
+ method: 'post',
2783
+ path: '/change-password',
2784
+ tags: ['client'],
2785
+ summary: 'Change password for authenticated user',
2786
+ request: {
2787
+ body: { content: { 'application/json': { schema: z.object({
2788
+ currentPassword: z.string(),
2789
+ newPassword: z.string(),
2790
+ }).passthrough() } }, required: true },
2791
+ },
2792
+ responses: {
2793
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2794
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2795
+ 401: { description: 'Authentication required or invalid password', content: { 'application/json': { schema: errorResponseSchema } } },
2796
+ },
2797
+ });
2798
+
2799
+ authRoute.openapi(changePassword, async (c) => {
2800
+ const userId = requireAuth(c.get('auth'));
2801
+ const bodyText = await c.req.text();
2802
+ let body: { currentPassword: string; newPassword: string };
2803
+ try { body = JSON.parse(bodyText); } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
2804
+ if (!body.currentPassword || !body.newPassword) {
2805
+ throw new EdgeBaseError(400, 'currentPassword and newPassword are required.', undefined, 'invalid-input');
2806
+ }
2807
+ if (body.newPassword.length < 8) {
2808
+ throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
2809
+ }
2810
+ await ensureAuthActionAllowed(c, 'changePassword', {
2811
+ userId,
2812
+ newPasswordLength: body.newPassword.length,
2813
+ });
2814
+
2815
+ const db = getAuthDb(c);
2816
+
2817
+ // Password policy validation
2818
+ const policyResult = await validatePassword(body.newPassword, getPasswordPolicyConfig(c.env));
2819
+ if (!policyResult.valid) {
2820
+ throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
2821
+ }
2822
+
2823
+ const user = await authService.getUserById(db, userId);
2824
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2825
+ if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
2826
+ if (!user.passwordHash) {
2827
+ throw new EdgeBaseError(403, 'This account uses OAuth sign-in. Password login is not available.', undefined, 'oauth-only');
2828
+ }
2829
+ if (user.isAnonymous === 1) {
2830
+ throw new EdgeBaseError(403, 'Anonymous accounts cannot change password.', undefined, 'anonymous-not-allowed');
2831
+ }
2832
+
2833
+ const valid = await verifyPassword(body.currentPassword, user.passwordHash as string);
2834
+ if (!valid) {
2835
+ throw new EdgeBaseError(401, 'Current password is incorrect.', undefined, 'invalid-password');
2836
+ }
2837
+
2838
+ // beforePasswordReset hook
2839
+ await executeAuthHook(c.env, c.executionCtx, 'beforePasswordReset', { userId }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
2840
+
2841
+ // Update password
2842
+ const newHash = await hashPassword(body.newPassword);
2843
+ await authService.updateUser(db, userId, { passwordHash: newHash });
2844
+
2845
+ // afterPasswordReset hook — non-blocking
2846
+ const changedUser = await authService.getUserById(db, userId);
2847
+ if (changedUser) {
2848
+ c.executionCtx.waitUntil(
2849
+ executeAuthHook(c.env, c.executionCtx, 'afterPasswordReset', authService.sanitizeUser(changedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
2850
+ );
2851
+ }
2852
+
2853
+ // Revoke all sessions + create new session
2854
+ await authService.deleteAllUserSessions(db, userId);
2855
+ const ip = getClientIP(c.env, c.req.raw);
2856
+ const userAgent = c.req.header('user-agent') || 'change-password';
2857
+ const session = await createSessionAndTokens(c.env, userId, ip, userAgent);
2858
+
2859
+ // Re-read user
2860
+ const updatedUser = await authService.getUserById(db, userId);
2861
+ if (updatedUser) {
2862
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(updatedUser));
2863
+ }
2864
+
2865
+ return c.json({
2866
+ user: updatedUser ? authService.sanitizeUser(updatedUser) : null,
2867
+ accessToken: session.accessToken,
2868
+ refreshToken: session.refreshToken,
2869
+ });
2870
+ });
2871
+
2872
+ // ─── Email Change (authenticated) ───
2873
+
2874
+ // POST /change-email — request email change (password re-confirm + verification email)
2875
+ const changeEmail = createRoute({
2876
+ operationId: 'authChangeEmail',
2877
+ method: 'post',
2878
+ path: '/change-email',
2879
+ tags: ['client'],
2880
+ summary: 'Request email change with password confirmation',
2881
+ request: {
2882
+ body: { content: { 'application/json': { schema: z.object({
2883
+ newEmail: z.string(),
2884
+ password: z.string(),
2885
+ }).passthrough() } }, required: true },
2886
+ },
2887
+ responses: {
2888
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2889
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2890
+ 401: { description: 'Password verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
2891
+ 409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
2892
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
2893
+ },
2894
+ });
2895
+
2896
+ authRoute.openapi(changeEmail, async (c) => {
2897
+ const userId = requireAuth(c.get('auth'));
2898
+ const body = await c.req.json<{
2899
+ newEmail: string;
2900
+ password: string;
2901
+ redirectUrl?: string;
2902
+ state?: string;
2903
+ }>();
2904
+
2905
+ if (!body.newEmail || !body.password) {
2906
+ throw new EdgeBaseError(400, 'newEmail and password are required.', undefined, 'invalid-input');
2907
+ }
2908
+
2909
+ const newEmail = body.newEmail.trim().toLowerCase();
2910
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
2911
+ throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
2912
+ }
2913
+ const redirect = parseClientRedirectInput(c.env, body);
2914
+ await ensureAuthActionAllowed(c, 'changeEmail', { userId, newEmail });
2915
+
2916
+ // Rate limit per user
2917
+ const rateKey = `email-change-rate:${userId}`;
2918
+ if (!counter.check(rateKey, 3, 3600)) {
2919
+ throw new EdgeBaseError(429, 'Too many email change requests. Try again later.', undefined, 'rate-limited');
2920
+ }
2921
+
2922
+ const db = getAuthDb(c);
2923
+
2924
+ // 1. Check new email is not already registered
2925
+ const existing = await lookupEmail(db, newEmail);
2926
+ if (existing) {
2927
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
2928
+ }
2929
+
2930
+ // 2. Verify password directly in D1
2931
+ const user = await authService.getUserById(db, userId);
2932
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2933
+ if (!user.passwordHash) {
2934
+ throw new EdgeBaseError(403, 'This account uses OAuth sign-in. Password-based email change is not available.', undefined, 'oauth-only');
2935
+ }
2936
+ if (user.disabled === 1) {
2937
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
2938
+ }
2939
+
2940
+ const valid = await verifyPassword(body.password, user.passwordHash as string);
2941
+ if (!valid) {
2942
+ throw new EdgeBaseError(401, 'Password verification failed.', undefined, 'invalid-password');
2943
+ }
2944
+
2945
+ const oldEmail = user.email as string;
2946
+
2947
+ // 3. Generate verification token and store in KV
2948
+ const token = crypto.randomUUID();
2949
+ await c.env.KV.put(
2950
+ `email-change:${token}`,
2951
+ JSON.stringify({ userId, newEmail, oldEmail }),
2952
+ { expirationTtl: 86400 },
2953
+ );
2954
+
2955
+ // 4. Send verification email to the NEW email address
2956
+ const provider = createEmailProvider(getEmailConfig(c.env), c.env);
2957
+ if (provider) {
2958
+ const appName = getAppName(c.env);
2959
+ const emailCfg = getEmailConfig(c.env);
2960
+ const fallbackVerifyUrl = emailCfg?.emailChangeUrl
2961
+ ? emailCfg.emailChangeUrl.replace('{token}', token)
2962
+ : `#verify-email-change?token=${token}`;
2963
+ const verifyUrl = buildEmailActionUrl({
2964
+ redirectUrl: redirect.redirectUrl,
2965
+ fallbackUrl: fallbackVerifyUrl,
2966
+ token,
2967
+ type: 'email-change',
2968
+ state: redirect.state,
2969
+ });
2970
+
2971
+ const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
2972
+ const html = renderEmailChange({
2973
+ appName,
2974
+ verifyUrl,
2975
+ token,
2976
+ newEmail,
2977
+ expiresInHours: 24,
2978
+ }, resolveLocalizedString(getEmailTemplates(c.env)?.emailChange, locale), locale);
2979
+
2980
+ const defaultSubject = getDefaultSubject(locale, 'emailChange').replace(/\{\{appName\}\}/g, appName);
2981
+ await sendMailWithHook(
2982
+ c.env, c.executionCtx, provider, 'emailChange', newEmail,
2983
+ resolveSubject(c.env, 'emailChange', defaultSubject, locale), html, locale,
2984
+ ).catch((err) => {
2985
+ console.error('[Email Change] Failed to send verification email:', err);
2986
+ });
2987
+ } else {
2988
+ const release = parseConfig(c.env)?.release ?? false;
2989
+ if (!release) {
2990
+ console.warn('[Email Change] Email provider not configured. Token:', token);
2991
+ }
2992
+ }
2993
+
2994
+ const release = parseConfig(c.env)?.release ?? false;
2995
+ if (!release) {
2996
+ const emailCfg = getEmailConfig(c.env);
2997
+ const fallbackVerifyUrl = emailCfg?.emailChangeUrl
2998
+ ? emailCfg.emailChangeUrl.replace('{token}', token)
2999
+ : `#verify-email-change?token=${token}`;
3000
+ const actionUrl = buildEmailActionUrl({
3001
+ redirectUrl: redirect.redirectUrl,
3002
+ fallbackUrl: fallbackVerifyUrl,
3003
+ token,
3004
+ type: 'email-change',
3005
+ state: redirect.state,
3006
+ });
3007
+ return c.json({ ok: true, token, actionUrl });
3008
+ }
3009
+
3010
+ return c.json({ ok: true });
3011
+ });
3012
+
3013
+ // POST /verify-email-change — verify token + swap email
3014
+ const verifyEmailChange = createRoute({
3015
+ operationId: 'authVerifyEmailChange',
3016
+ method: 'post',
3017
+ path: '/verify-email-change',
3018
+ tags: ['client'],
3019
+ summary: 'Verify email change token',
3020
+ request: {
3021
+ body: { content: { 'application/json': { schema: z.object({
3022
+ token: z.string(),
3023
+ }).passthrough() } }, required: true },
3024
+ },
3025
+ responses: {
3026
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3027
+ 400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
3028
+ 409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
3029
+ },
3030
+ });
3031
+
3032
+ authRoute.openapi(verifyEmailChange, async (c) => {
3033
+ const body = await c.req.json<{ token: string }>();
3034
+ if (!body.token) throw new EdgeBaseError(400, 'Verification token is required.', undefined, 'invalid-input');
3035
+
3036
+ await ensureAuthActionAllowed(c, 'verifyEmailChange', body as unknown as Record<string, unknown>);
3037
+
3038
+ const db = getAuthDb(c);
3039
+
3040
+ // 1. Read from KV
3041
+ const data = await c.env.KV.get(`email-change:${body.token}`, 'json') as {
3042
+ userId: string; newEmail: string; oldEmail: string;
3043
+ } | null;
3044
+
3045
+ if (!data) {
3046
+ throw new EdgeBaseError(400, 'Invalid or expired email change token.', undefined, 'invalid-token');
3047
+ }
3048
+
3049
+ const { userId, newEmail, oldEmail } = data;
3050
+
3051
+ // 2. Delete KV token (single-use)
3052
+ await c.env.KV.delete(`email-change:${body.token}`).catch(() => {});
3053
+
3054
+ // 3. Check new email is still not registered (race condition check)
3055
+ const existing = await lookupEmail(db, newEmail);
3056
+ if (existing) {
3057
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
3058
+ }
3059
+
3060
+ // 4. Register new email as pending in D1
3061
+ try {
3062
+ await registerEmailPending(db, newEmail, userId);
3063
+ } catch (err) {
3064
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
3065
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
3066
+ }
3067
+ throw new EdgeBaseError(500, 'Email change failed.', undefined, 'internal-error');
3068
+ }
3069
+
3070
+ // 5. Update user email directly in D1
3071
+ try {
3072
+ await authService.updateUser(db, userId, { email: newEmail });
3073
+ } catch {
3074
+ await deleteEmailPending(db, newEmail).catch(() => {});
3075
+ throw new EdgeBaseError(500, 'Email change failed.', undefined, 'internal-error');
3076
+ }
3077
+
3078
+ // 6. Confirm new email + delete old email in D1
3079
+ await confirmEmail(db, newEmail, userId);
3080
+ if (oldEmail) {
3081
+ await deleteEmail(db, oldEmail).catch(() => {});
3082
+ }
3083
+
3084
+ // Sync _users_public
3085
+ const user = await authService.getUserById(db, userId);
3086
+ if (user) {
3087
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
3088
+ }
3089
+
3090
+ return c.json({ ok: true, user: user ? authService.sanitizeUser(user) : { id: userId, email: newEmail } });
3091
+ });
3092
+
3093
+ // ─── Passkeys/WebAuthn ────────────────────────────────────────────────────────
3094
+
3095
+ // POST /passkeys/register-options — authenticated, generate registration options
3096
+ const passkeysRegisterOptions = createRoute({
3097
+ operationId: 'authPasskeysRegisterOptions',
3098
+ method: 'post',
3099
+ path: '/passkeys/register-options',
3100
+ tags: ['client'],
3101
+ summary: 'Generate passkey registration options',
3102
+ responses: {
3103
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3104
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3105
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3106
+ },
3107
+ });
3108
+
3109
+ authRoute.openapi(passkeysRegisterOptions, async (c) => {
3110
+ const userId = requireAuth(c.get('auth'));
3111
+ await ensureAuthActionAllowed(c, 'passkeysRegisterOptions', { userId });
3112
+ const db = getAuthDb(c);
3113
+ const passkeysConfig = getPasskeysConfig(c.env);
3114
+ if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
3115
+
3116
+ const { generateRegistrationOptions } = await import('@simplewebauthn/server');
3117
+
3118
+ const user = await authService.getUserById(db, userId);
3119
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3120
+ if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
3121
+
3122
+ // Gather existing credentials for exclusion
3123
+ const existingCreds = await authService.listWebAuthnCredentials(db, userId);
3124
+
3125
+ const options = await generateRegistrationOptions({
3126
+ rpName: passkeysConfig.rpName,
3127
+ rpID: passkeysConfig.rpID,
3128
+ userName: (user.email as string) || userId,
3129
+ userDisplayName: (user.displayName as string) || (user.email as string) || '',
3130
+ excludeCredentials: existingCreds.map((cred) => ({
3131
+ id: cred.credentialId,
3132
+ transports: cred.transports ? JSON.parse(cred.transports) : undefined,
3133
+ })),
3134
+ authenticatorSelection: {
3135
+ residentKey: 'preferred',
3136
+ userVerification: 'preferred',
3137
+ },
3138
+ attestationType: 'none',
3139
+ });
3140
+
3141
+ // Store challenge in KV (TTL 5 min)
3142
+ await c.env.KV.put(
3143
+ `webauthn-challenge:${userId}`,
3144
+ options.challenge,
3145
+ { expirationTtl: 300 },
3146
+ );
3147
+
3148
+ return c.json({ options });
3149
+ });
3150
+
3151
+ // POST /passkeys/register — authenticated, verify registration and store credential
3152
+ const passkeysRegister = createRoute({
3153
+ operationId: 'authPasskeysRegister',
3154
+ method: 'post',
3155
+ path: '/passkeys/register',
3156
+ tags: ['client'],
3157
+ summary: 'Verify and store passkey registration',
3158
+ request: {
3159
+ body: { content: { 'application/json': { schema: z.object({}).passthrough() } }, required: true },
3160
+ },
3161
+ responses: {
3162
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3163
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3164
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3165
+ },
3166
+ });
3167
+
3168
+ authRoute.openapi(passkeysRegister, async (c) => {
3169
+ const userId = requireAuth(c.get('auth'));
3170
+ const db = getAuthDb(c);
3171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3172
+ const body = await c.req.json<{ response: any }>();
3173
+ if (!body.response) throw new EdgeBaseError(400, 'Registration response is required.', undefined, 'invalid-input');
3174
+ await ensureAuthActionAllowed(c, 'passkeysRegister', { userId });
3175
+
3176
+ const passkeysConfig = getPasskeysConfig(c.env);
3177
+ if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
3178
+
3179
+ const { verifyRegistrationResponse } = await import('@simplewebauthn/server');
3180
+
3181
+ const user = await authService.getUserById(db, userId);
3182
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3183
+ if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
3184
+
3185
+ // Retrieve challenge from KV
3186
+ const expectedChallenge = await c.env.KV.get(`webauthn-challenge:${userId}`);
3187
+ if (!expectedChallenge) throw new EdgeBaseError(400, 'Challenge expired or not found. Please request new registration options.', undefined, 'challenge-expired');
3188
+
3189
+ // Clean up challenge (single-use)
3190
+ await c.env.KV.delete(`webauthn-challenge:${userId}`);
3191
+
3192
+ const expectedOrigin = Array.isArray(passkeysConfig.origin) ? passkeysConfig.origin : [passkeysConfig.origin];
3193
+
3194
+ let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
3195
+ try {
3196
+ verification = await verifyRegistrationResponse({
3197
+ response: body.response,
3198
+ expectedChallenge,
3199
+ expectedOrigin,
3200
+ expectedRPID: passkeysConfig.rpID,
3201
+ // Registration/auth options use userVerification: 'preferred', so server verification
3202
+ // must not silently upgrade that requirement to "required".
3203
+ requireUserVerification: false,
3204
+ });
3205
+ } catch (error) {
3206
+ throw new EdgeBaseError(
3207
+ 400,
3208
+ error instanceof Error ? error.message : 'Passkey registration verification failed.',
3209
+ undefined,
3210
+ 'invalid-input',
3211
+ );
3212
+ }
3213
+
3214
+ if (!verification.verified || !verification.registrationInfo) {
3215
+ throw new EdgeBaseError(400, 'Registration verification failed.', undefined, 'invalid-input');
3216
+ }
3217
+
3218
+ const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
3219
+ const transports = body.response.response?.transports || [];
3220
+
3221
+ // Convert credentialPublicKey Uint8Array to base64 for TEXT storage
3222
+ const pubKeyBase64 = btoa(String.fromCharCode(...credentialPublicKey));
3223
+
3224
+ const credId = generateId();
3225
+
3226
+ try {
3227
+ await authService.createWebAuthnCredential(db, {
3228
+ id: credId,
3229
+ userId,
3230
+ credentialId: credentialID,
3231
+ credentialPublicKey: pubKeyBase64,
3232
+ counter,
3233
+ transports: JSON.stringify(transports),
3234
+ });
3235
+
3236
+ // Register in D1 passkey index
3237
+ await registerPasskey(db, credentialID, userId);
3238
+ } catch (error) {
3239
+ console.error('[Passkeys] Failed to persist registered credential:', error);
3240
+ throw new EdgeBaseError(
3241
+ 500,
3242
+ error instanceof Error
3243
+ ? `Passkey registration persistence failed: ${error.message}`
3244
+ : 'Passkey registration persistence failed.',
3245
+ undefined,
3246
+ 'internal-error',
3247
+ );
3248
+ }
3249
+
3250
+ return c.json({ ok: true, credentialId: credentialID });
3251
+ });
3252
+
3253
+ // POST /passkeys/auth-options — public, generate authentication options
3254
+ const passkeysAuthOptions = createRoute({
3255
+ operationId: 'authPasskeysAuthOptions',
3256
+ method: 'post',
3257
+ path: '/passkeys/auth-options',
3258
+ tags: ['client'],
3259
+ summary: 'Generate passkey authentication options',
3260
+ request: {
3261
+ body: { content: { 'application/json': { schema: z.object({
3262
+ email: z.string().optional(),
3263
+ }).passthrough() } }, required: false },
3264
+ },
3265
+ responses: {
3266
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3267
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3268
+ 404: { description: 'User not found', content: { 'application/json': { schema: errorResponseSchema } } },
3269
+ },
3270
+ });
3271
+
3272
+ authRoute.openapi(passkeysAuthOptions, async (c) => {
3273
+ const db = getAuthDb(c);
3274
+ const passkeysConfig = getPasskeysConfig(c.env);
3275
+ if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
3276
+
3277
+ const { generateAuthenticationOptions } = await import('@simplewebauthn/server');
3278
+ const body: { email?: string } = await c.req.json<{ email?: string }>().catch(() => ({}));
3279
+ const email = typeof body.email === 'string' ? body.email.toLowerCase().trim() : undefined;
3280
+ await ensureAuthActionAllowed(c, 'passkeysAuthOptions', email ? { email } : null);
3281
+ let userId: string | undefined;
3282
+
3283
+ // If email is provided, look up the user to get their specific credentials
3284
+ if (email) {
3285
+ const emailLookup = await lookupEmail(db, email);
3286
+ if (!emailLookup) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3287
+ userId = emailLookup.userId;
3288
+ }
3289
+
3290
+ // Transport strings from DB are valid AuthenticatorTransportFuture values
3291
+ type TransportFuture = 'ble' | 'cable' | 'hybrid' | 'internal' | 'nfc' | 'smart-card' | 'usb';
3292
+ let allowCredentials: { id: string; transports?: TransportFuture[] }[] | undefined;
3293
+
3294
+ // If userId is provided, limit to that user's credentials
3295
+ if (userId) {
3296
+ const creds = await authService.listWebAuthnCredentials(db, userId);
3297
+ if (creds.length === 0) throw new EdgeBaseError(400, 'No passkeys registered for this user.', undefined, 'not-found');
3298
+ allowCredentials = creds.map((cred) => ({
3299
+ id: cred.credentialId,
3300
+ transports: cred.transports ? JSON.parse(cred.transports) as TransportFuture[] : undefined,
3301
+ }));
3302
+ }
3303
+
3304
+ const options = await generateAuthenticationOptions({
3305
+ rpID: passkeysConfig.rpID,
3306
+ allowCredentials,
3307
+ userVerification: 'preferred',
3308
+ });
3309
+
3310
+ // Store challenge in KV (keyed by challenge itself for discoverable flow)
3311
+ await c.env.KV.put(
3312
+ `webauthn-auth-challenge:${options.challenge}`,
3313
+ JSON.stringify({ userId: userId || null }),
3314
+ { expirationTtl: 300 },
3315
+ );
3316
+
3317
+ return c.json({ options });
3318
+ });
3319
+
3320
+ // POST /passkeys/authenticate — public, verify assertion and create session
3321
+ const passkeysAuthenticate = createRoute({
3322
+ operationId: 'authPasskeysAuthenticate',
3323
+ method: 'post',
3324
+ path: '/passkeys/authenticate',
3325
+ tags: ['client'],
3326
+ summary: 'Authenticate with passkey',
3327
+ request: {
3328
+ body: { content: { 'application/json': { schema: z.object({}).passthrough() } }, required: true },
3329
+ },
3330
+ responses: {
3331
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3332
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3333
+ 401: { description: 'Authentication failed', content: { 'application/json': { schema: errorResponseSchema } } },
3334
+ },
3335
+ });
3336
+
3337
+ authRoute.openapi(passkeysAuthenticate, async (c) => {
3338
+ const db = getAuthDb(c);
3339
+ const passkeysConfig = getPasskeysConfig(c.env);
3340
+ if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
3341
+
3342
+ const { verifyAuthenticationResponse } = await import('@simplewebauthn/server');
3343
+
3344
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3345
+ const body = await c.req.json<{ response: any }>();
3346
+ if (!body.response) throw new EdgeBaseError(400, 'Authentication response is required.', undefined, 'invalid-input');
3347
+
3348
+ const credentialId = body.response.id as string;
3349
+ if (!credentialId) throw new EdgeBaseError(400, 'Credential ID is required in the response.', undefined, 'invalid-input');
3350
+ await ensureAuthActionAllowed(c, 'passkeysAuthenticate', { credentialId });
3351
+
3352
+ const ip = getClientIP(c.env, c.req.raw);
3353
+ const userAgent = c.req.header('User-Agent') || 'passkey';
3354
+
3355
+ // Find credential in D1 (check existence before challenge validation)
3356
+ const credRow = await authService.getWebAuthnCredential(db, credentialId);
3357
+ if (!credRow) throw new EdgeBaseError(400, 'Unknown credential.', undefined, 'invalid-input');
3358
+
3359
+ // Extract challenge from clientDataJSON
3360
+ const clientDataJSON = body.response.response?.clientDataJSON as string;
3361
+ if (!clientDataJSON) throw new EdgeBaseError(400, 'clientDataJSON is required in the response.', undefined, 'invalid-input');
3362
+
3363
+ let parsedChallenge: string;
3364
+ try {
3365
+ const decoded = atob(clientDataJSON.replace(/-/g, '+').replace(/_/g, '/'));
3366
+ const parsed = JSON.parse(decoded);
3367
+ parsedChallenge = parsed.challenge;
3368
+ } catch {
3369
+ throw new EdgeBaseError(400, 'Invalid clientDataJSON.', undefined, 'invalid-input');
3370
+ }
3371
+
3372
+ // Retrieve challenge data from KV
3373
+ const challengeData = await c.env.KV.get(`webauthn-auth-challenge:${parsedChallenge}`);
3374
+ if (!challengeData) throw new EdgeBaseError(400, 'Challenge expired or not found.', undefined, 'challenge-expired');
3375
+
3376
+ // Clean up challenge (single-use)
3377
+ await c.env.KV.delete(`webauthn-auth-challenge:${parsedChallenge}`);
3378
+
3379
+ const userId = credRow.userId as string;
3380
+ const user = await authService.getUserById(db, userId);
3381
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3382
+ if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
3383
+
3384
+ // Reconstruct Uint8Array from base64 string
3385
+ const pubKeyBinary = Uint8Array.from(atob(credRow.credentialPublicKey as string), (ch) => ch.charCodeAt(0));
3386
+
3387
+ const expectedOrigin = Array.isArray(passkeysConfig.origin) ? passkeysConfig.origin : [passkeysConfig.origin];
3388
+
3389
+ let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
3390
+ try {
3391
+ verification = await verifyAuthenticationResponse({
3392
+ response: body.response,
3393
+ expectedChallenge: parsedChallenge,
3394
+ expectedOrigin,
3395
+ expectedRPID: passkeysConfig.rpID,
3396
+ authenticator: {
3397
+ credentialID: credRow.credentialId as string,
3398
+ credentialPublicKey: pubKeyBinary,
3399
+ counter: credRow.counter as number,
3400
+ },
3401
+ requireUserVerification: false,
3402
+ });
3403
+ } catch (error) {
3404
+ throw new EdgeBaseError(
3405
+ 401,
3406
+ error instanceof Error ? error.message : 'Passkey authentication verification failed.',
3407
+ undefined,
3408
+ 'invalid-credentials',
3409
+ );
3410
+ }
3411
+
3412
+ if (!verification.verified) {
3413
+ throw new EdgeBaseError(401, 'Authentication verification failed.', undefined, 'invalid-credentials');
3414
+ }
3415
+
3416
+ // Update counter
3417
+ await authService.updateWebAuthnCounter(db, credentialId, verification.authenticationInfo.newCounter);
3418
+
3419
+ // Run beforeSignIn hook
3420
+ const sanitizedUser = authService.sanitizeUser(user);
3421
+ const hookResult = await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', sanitizedUser, { ip, userAgent, workerUrl: getWorkerUrl(c.req.url, c.env) });
3422
+ if (hookResult?.blocked) {
3423
+ throw new EdgeBaseError(403, 'Sign-in blocked by hook.', undefined, 'hook-rejected');
3424
+ }
3425
+
3426
+ // MFA Check
3427
+ const mfaConfig = getMfaConfig(c.env);
3428
+ if (mfaConfig?.totp) {
3429
+ const factors = await authService.listVerifiedMfaFactors(db, userId);
3430
+ if (factors.length > 0) {
3431
+ const mfaTicket = crypto.randomUUID();
3432
+ await c.env.KV.put(
3433
+ `mfa-ticket:${mfaTicket}`,
3434
+ JSON.stringify({ userId }),
3435
+ { expirationTtl: 300 },
3436
+ );
3437
+ return c.json({
3438
+ mfaRequired: true,
3439
+ mfaTicket,
3440
+ factors: factors.map((f) => ({ id: f.id, type: f.type })),
3441
+ });
3442
+ }
3443
+ }
3444
+
3445
+ // Create session
3446
+ const session = await createSessionAndTokens(c.env, userId, ip, userAgent);
3447
+
3448
+ // Run afterSignIn hook (non-blocking)
3449
+ c.executionCtx.waitUntil(
3450
+ executeAuthHook(c.env, c.executionCtx, 'afterSignIn', sanitizedUser, { ip, userAgent, workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
3451
+ );
3452
+
3453
+ return c.json({
3454
+ user: sanitizedUser,
3455
+ accessToken: session.accessToken,
3456
+ refreshToken: session.refreshToken,
3457
+ });
3458
+ });
3459
+
3460
+ // GET /passkeys — list passkeys for authenticated user
3461
+ const passkeysList = createRoute({
3462
+ operationId: 'authPasskeysList',
3463
+ method: 'get',
3464
+ path: '/passkeys',
3465
+ tags: ['client'],
3466
+ summary: 'List passkeys for authenticated user',
3467
+ responses: {
3468
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3469
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3470
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3471
+ },
3472
+ });
3473
+
3474
+ authRoute.openapi(passkeysList, async (c) => {
3475
+ const userId = requireAuth(c.get('auth'));
3476
+ await ensureAuthActionAllowed(c, 'passkeysList', { userId });
3477
+ const db = getAuthDb(c);
3478
+
3479
+ const user = await authService.getUserById(db, userId);
3480
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3481
+
3482
+ const creds = await authService.listWebAuthnCredentials(db, userId);
3483
+
3484
+ return c.json({
3485
+ passkeys: creds.map((cred) => ({
3486
+ id: cred.id,
3487
+ credentialId: cred.credentialId,
3488
+ transports: cred.transports ? JSON.parse(cred.transports) : [],
3489
+ createdAt: cred.createdAt,
3490
+ })),
3491
+ });
3492
+ });
3493
+
3494
+ // DELETE /passkeys/:credentialId — delete a passkey for authenticated user
3495
+ const passkeysDelete = createRoute({
3496
+ operationId: 'authPasskeysDelete',
3497
+ method: 'delete',
3498
+ path: '/passkeys/{credentialId}',
3499
+ tags: ['client'],
3500
+ summary: 'Delete a passkey',
3501
+ request: {
3502
+ params: z.object({ credentialId: z.string() }),
3503
+ },
3504
+ responses: {
3505
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3506
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3507
+ 404: { description: 'Passkey not found', content: { 'application/json': { schema: errorResponseSchema } } },
3508
+ },
3509
+ });
3510
+
3511
+ authRoute.openapi(passkeysDelete, async (c) => {
3512
+ const userId = requireAuth(c.get('auth'));
3513
+ const db = getAuthDb(c);
3514
+ const credentialId = decodeURIComponent(c.req.param('credentialId')!);
3515
+ await ensureAuthActionAllowed(c, 'passkeysDelete', { userId, credentialId });
3516
+
3517
+ // Verify credential belongs to user
3518
+ const cred = await authService.getWebAuthnCredential(db, credentialId);
3519
+ if (!cred || cred.userId !== userId) throw new EdgeBaseError(404, 'Passkey not found.', undefined, 'not-found');
3520
+
3521
+ // Delete from _webauthn_credentials table
3522
+ await authService.deleteWebAuthnCredential(db, credentialId, userId);
3523
+
3524
+ // Also remove from D1 passkey index
3525
+ await deletePasskey(db, credentialId).catch(() => {});
3526
+
3527
+ return c.json({ ok: true });
3528
+ });
3529
+
3530
+ // ─── GET /me — Current authenticated user info ──────────────
3531
+
3532
+ const getMe = createRoute({
3533
+ operationId: 'authGetMe',
3534
+ method: 'get',
3535
+ path: '/me',
3536
+ tags: ['client'],
3537
+ summary: 'Get current authenticated user info',
3538
+ responses: {
3539
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3540
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3541
+ },
3542
+ });
3543
+
3544
+ authRoute.openapi(getMe, async (c) => {
3545
+ const auth = c.get('auth');
3546
+ if (!auth) {
3547
+ return c.json({ code: 401, message: 'Authentication required.' }, 401);
3548
+ }
3549
+ const userId = requireAuth(auth);
3550
+ await ensureAuthActionAllowed(c, 'getMe', { userId });
3551
+ const db = getAuthDb(c);
3552
+
3553
+ const user = await authService.getUserById(db, userId);
3554
+ if (!user) return c.json({ code: 404, message: 'User not found' }, 404);
3555
+
3556
+ return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) });
3557
+ });
3558
+
3559
+ const updateProfile = createRoute({
3560
+ operationId: 'authUpdateProfile',
3561
+ method: 'patch',
3562
+ path: '/profile',
3563
+ tags: ['client'],
3564
+ summary: 'Update user profile',
3565
+ request: {
3566
+ body: { content: { 'application/json': { schema: z.object({}).passthrough() } }, required: true },
3567
+ },
3568
+ responses: {
3569
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3570
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3571
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3572
+ },
3573
+ });
3574
+
3575
+ authRoute.openapi(updateProfile, async (c) => {
3576
+ const userId = requireAuth(c.get('auth'));
3577
+ const db = getAuthDb(c);
3578
+
3579
+ const body = c.req.valid('json') as {
3580
+ displayName?: string;
3581
+ avatarUrl?: string;
3582
+ emailVisibility?: 'public' | 'private';
3583
+ metadata?: Record<string, unknown>;
3584
+ locale?: string | null;
3585
+ };
3586
+ await ensureAuthActionAllowed(c, 'updateProfile', {
3587
+ userId,
3588
+ ...(body.displayName !== undefined ? { displayName: body.displayName } : {}),
3589
+ ...(body.avatarUrl !== undefined ? { avatarUrl: body.avatarUrl } : {}),
3590
+ ...(body.emailVisibility !== undefined ? { emailVisibility: body.emailVisibility } : {}),
3591
+ ...(body.locale !== undefined ? { locale: body.locale } : {}),
3592
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
3593
+ });
3594
+
3595
+ const updates: Record<string, unknown> = {};
3596
+
3597
+ if (body.displayName !== undefined) {
3598
+ if (typeof body.displayName === 'string' && body.displayName.length > 200) {
3599
+ throw new EdgeBaseError(400, 'Display name must not exceed 200 characters.', undefined, 'display-name-too-long');
3600
+ }
3601
+ updates.displayName = body.displayName;
3602
+ }
3603
+ if (body.avatarUrl !== undefined) {
3604
+ if (typeof body.avatarUrl === 'string' && body.avatarUrl.length > 2048) {
3605
+ throw new EdgeBaseError(400, 'Avatar URL must not exceed 2048 characters.', undefined, 'invalid-input');
3606
+ }
3607
+ updates.avatarUrl = body.avatarUrl;
3608
+ }
3609
+
3610
+ // User-writable metadata (16KB limit)
3611
+ if (body.metadata !== undefined) {
3612
+ const metadataStr = JSON.stringify(body.metadata);
3613
+ if (metadataStr.length > 16384) {
3614
+ throw new EdgeBaseError(400, 'metadata exceeds 16KB limit.', undefined, 'invalid-input');
3615
+ }
3616
+ updates.metadata = metadataStr;
3617
+ }
3618
+
3619
+ // User locale preference for i18n emails
3620
+ if (body.locale !== undefined) {
3621
+ const localeVal = body.locale;
3622
+ if (localeVal !== null && !/^[a-z]{2}(-[A-Z]{2})?$/.test(localeVal as string)) {
3623
+ throw new EdgeBaseError(400, 'Invalid locale format. Use ISO 639-1 (e.g. "en", "ko", "ja-JP").', undefined, 'invalid-locale');
3624
+ }
3625
+ updates.locale = localeVal ?? 'en';
3626
+ }
3627
+
3628
+ // emailVisibility change handling
3629
+ let isPrivacyDowngrade = false;
3630
+ let previousVisibility: string | null = null;
3631
+
3632
+ if (body.emailVisibility !== undefined) {
3633
+ if (!['public', 'private'].includes(body.emailVisibility)) {
3634
+ throw new EdgeBaseError(400, 'emailVisibility must be "public" or "private".', undefined, 'invalid-input');
3635
+ }
3636
+ isPrivacyDowngrade = body.emailVisibility === 'private';
3637
+ if (isPrivacyDowngrade) {
3638
+ const current = await authService.getUserById(db, userId);
3639
+ previousVisibility = (current?.emailVisibility as string) ?? 'private';
3640
+ }
3641
+ updates.emailVisibility = body.emailVisibility;
3642
+ }
3643
+
3644
+ if (Object.keys(updates).length === 0) {
3645
+ throw new EdgeBaseError(400, 'No valid fields to update. Allowed fields: displayName, avatarUrl, emailVisibility, metadata.', undefined, 'no-fields-to-update');
3646
+ }
3647
+
3648
+ await authService.updateUser(db, userId, updates);
3649
+
3650
+ const user = await authService.getUserById(db, userId);
3651
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3652
+
3653
+ // Sync _users_public
3654
+ if (isPrivacyDowngrade && previousVisibility === 'public') {
3655
+ // Synchronous processing for privacy downgrade
3656
+ try {
3657
+ await syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user), true);
3658
+ } catch {
3659
+ // Compensating transaction: restore previous emailVisibility
3660
+ await authService.updateUser(db, userId, { emailVisibility: previousVisibility });
3661
+ throw new EdgeBaseError(500, 'Privacy setting change failed. Please try again.', undefined, 'internal-error');
3662
+ }
3663
+ } else {
3664
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
3665
+ }
3666
+
3667
+ // displayName is included in JWT, so issue fresh tokens when it changes
3668
+ if (body.displayName !== undefined) {
3669
+ const accessToken = await generateAccessToken(c.env, user);
3670
+ return c.json({ user: authService.sanitizeUser(user), accessToken });
3671
+ }
3672
+
3673
+ return c.json({ user: authService.sanitizeUser(user) });
3674
+ });
3675
+
3676
+ const getSessions = createRoute({
3677
+ operationId: 'authGetSessions',
3678
+ method: 'get',
3679
+ path: '/sessions',
3680
+ tags: ['client'],
3681
+ summary: 'List active sessions',
3682
+ responses: {
3683
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3684
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3685
+ },
3686
+ });
3687
+
3688
+ authRoute.openapi(getSessions, async (c) => {
3689
+ const userId = requireAuth(c.get('auth'));
3690
+ await ensureAuthActionAllowed(c, 'getSessions', { userId });
3691
+ const db = getAuthDb(c);
3692
+
3693
+ const sessions = await authService.listUserSessions(db, userId);
3694
+
3695
+ return c.json({ sessions });
3696
+ });
3697
+
3698
+ const deleteSession = createRoute({
3699
+ operationId: 'authDeleteSession',
3700
+ method: 'delete',
3701
+ path: '/sessions/{id}',
3702
+ tags: ['client'],
3703
+ summary: 'Delete a session',
3704
+ request: {
3705
+ params: z.object({ id: z.string() }),
3706
+ },
3707
+ responses: {
3708
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3709
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3710
+ 404: { description: 'Session not found', content: { 'application/json': { schema: errorResponseSchema } } },
3711
+ },
3712
+ });
3713
+
3714
+ authRoute.openapi(deleteSession, async (c) => {
3715
+ const userId = requireAuth(c.get('auth'));
3716
+ const db = getAuthDb(c);
3717
+ const sessionId = c.req.param('id')!;
3718
+ await ensureAuthActionAllowed(c, 'deleteSession', { userId, sessionId });
3719
+
3720
+ await authService.deleteSessionForUser(db, sessionId, userId);
3721
+
3722
+ return c.json({ ok: true });
3723
+ });
3724
+
3725
+ const getIdentities = createRoute({
3726
+ operationId: 'authGetIdentities',
3727
+ method: 'get',
3728
+ path: '/identities',
3729
+ tags: ['client'],
3730
+ summary: 'List linked sign-in identities for the current user',
3731
+ responses: {
3732
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3733
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3734
+ },
3735
+ });
3736
+
3737
+ authRoute.openapi(getIdentities, async (c) => {
3738
+ const userId = requireAuth(c.get('auth'));
3739
+ await ensureAuthActionAllowed(c, 'getIdentities', { userId });
3740
+ const db = getAuthDb(c);
3741
+
3742
+ const { user, oauthAccounts, summary } = await getIdentityState(c.env, db, userId);
3743
+
3744
+ return c.json({
3745
+ identities: oauthAccounts.map((account) => ({
3746
+ id: account.id,
3747
+ kind: 'oauth',
3748
+ provider: account.provider,
3749
+ providerUserId: account.providerUserId,
3750
+ createdAt: account.createdAt,
3751
+ canUnlink: summary.total > 1,
3752
+ })),
3753
+ methods: {
3754
+ ...summary,
3755
+ email: typeof user.email === 'string' ? user.email : null,
3756
+ phone: typeof user.phone === 'string' ? user.phone : null,
3757
+ },
3758
+ });
3759
+ });
3760
+
3761
+ const deleteIdentity = createRoute({
3762
+ operationId: 'authDeleteIdentity',
3763
+ method: 'delete',
3764
+ path: '/identities/{identityId}',
3765
+ tags: ['client'],
3766
+ summary: 'Unlink a linked sign-in identity',
3767
+ request: {
3768
+ params: z.object({ identityId: z.string() }),
3769
+ },
3770
+ responses: {
3771
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3772
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3773
+ 404: { description: 'Identity not found', content: { 'application/json': { schema: errorResponseSchema } } },
3774
+ },
3775
+ });
3776
+
3777
+ authRoute.openapi(deleteIdentity, async (c) => {
3778
+ const userId = requireAuth(c.get('auth'));
3779
+ const db = getAuthDb(c);
3780
+ const identityId = c.req.param('identityId')!;
3781
+ await ensureAuthActionAllowed(c, 'deleteIdentity', { userId, identityId });
3782
+
3783
+ const { oauthAccounts, summary } = await getIdentityState(c.env, db, userId);
3784
+ const identity = oauthAccounts.find((account) => account.id === identityId);
3785
+ if (!identity) {
3786
+ throw new EdgeBaseError(404, 'Identity not found.', undefined, 'not-found');
3787
+ }
3788
+ if (summary.total <= 1) {
3789
+ throw new EdgeBaseError(400, 'Cannot unlink the last sign-in method.', undefined, 'invalid-input');
3790
+ }
3791
+
3792
+ await authService.deleteOAuthAccount(db, identity.id);
3793
+ await deleteOAuth(db, identity.provider, identity.providerUserId).catch(() => {});
3794
+
3795
+ const next = await getIdentityState(c.env, db, userId);
3796
+ return c.json({
3797
+ ok: true,
3798
+ identities: next.oauthAccounts.map((account) => ({
3799
+ id: account.id,
3800
+ kind: 'oauth',
3801
+ provider: account.provider,
3802
+ providerUserId: account.providerUserId,
3803
+ createdAt: account.createdAt,
3804
+ canUnlink: next.summary.total > 1,
3805
+ })),
3806
+ methods: next.summary,
3807
+ });
3808
+ });
3809
+
3810
+ // ─── Anonymous → Email/Password linking ───
3811
+
3812
+ const linkEmail = createRoute({
3813
+ operationId: 'authLinkEmail',
3814
+ method: 'post',
3815
+ path: '/link/email',
3816
+ tags: ['client'],
3817
+ summary: 'Link email and password to existing account',
3818
+ request: {
3819
+ body: { content: { 'application/json': { schema: z.object({
3820
+ email: z.string(),
3821
+ password: z.string(),
3822
+ }).passthrough() } }, required: true },
3823
+ },
3824
+ responses: {
3825
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3826
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3827
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3828
+ 409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
3829
+ },
3830
+ });
3831
+
3832
+ authRoute.openapi(linkEmail, async (c) => {
3833
+ const userId = requireAuth(c.get('auth'));
3834
+ const db = getAuthDb(c);
3835
+ const body = await c.req.json<{ email: string; password: string }>();
3836
+
3837
+ if (!body.email || !body.password) {
3838
+ throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'invalid-input');
3839
+ }
3840
+ body.email = body.email.trim().toLowerCase();
3841
+ if (body.password.length < 8) {
3842
+ throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
3843
+ }
3844
+ await ensureAuthActionAllowed(c, 'linkEmail', { userId, email: body.email });
3845
+
3846
+ // Verify user exists and is anonymous
3847
+ const user = await authService.getUserById(db, userId);
3848
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3849
+ if (!user.isAnonymous) throw new EdgeBaseError(400, 'User is not anonymous.', undefined, 'invalid-input');
3850
+ if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
3851
+
3852
+ // Check email uniqueness in D1
3853
+ const existing = await lookupEmail(db, body.email);
3854
+ if (existing) {
3855
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
3856
+ }
3857
+
3858
+ // Register email as pending in D1
3859
+ try {
3860
+ await registerEmailPending(db, body.email, userId);
3861
+ } catch (err) {
3862
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
3863
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
3864
+ }
3865
+ throw err;
3866
+ }
3867
+
3868
+ // Update user in D1
3869
+ const passwordHash = await hashPassword(body.password);
3870
+ try {
3871
+ await authService.updateUser(db, userId, {
3872
+ email: body.email,
3873
+ passwordHash,
3874
+ isAnonymous: 0,
3875
+ });
3876
+ } catch (err) {
3877
+ await deleteEmailPending(db, body.email).catch(() => {});
3878
+ throw new EdgeBaseError(500, `Link failed: ${(err as Error).message}`, undefined, 'internal-error');
3879
+ }
3880
+
3881
+ // Confirm email in D1
3882
+ await confirmEmail(db, body.email, userId);
3883
+
3884
+ // Best-effort: delete from _anon_index
3885
+ await deleteAnon(db, userId).catch(() => {});
3886
+
3887
+ // Sync _users_public
3888
+ const updatedUser = await authService.getUserById(db, userId);
3889
+ if (updatedUser) {
3890
+ syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(updatedUser));
3891
+ }
3892
+
3893
+ // Generate new tokens (isAnonymous = false now)
3894
+ const session = await createSessionAndTokens(c.env, userId, '0.0.0.0', 'link');
3895
+
3896
+ return c.json({
3897
+ user: authService.sanitizeUser(updatedUser || user),
3898
+ accessToken: session.accessToken,
3899
+ refreshToken: session.refreshToken,
3900
+ });
3901
+ });
3902
+
3903
+ // ─── Email Verification & Password Reset (M14,) ───
3904
+
3905
+ const requestEmailVerification = createRoute({
3906
+ operationId: 'authRequestEmailVerification',
3907
+ method: 'post',
3908
+ path: '/request-email-verification',
3909
+ tags: ['client'],
3910
+ summary: 'Send a verification email to the current authenticated user',
3911
+ request: {
3912
+ body: { content: { 'application/json': { schema: z.object({
3913
+ redirectUrl: z.string().optional(),
3914
+ state: z.string().optional(),
3915
+ }).passthrough() } }, required: false },
3916
+ },
3917
+ responses: {
3918
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
3919
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
3920
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
3921
+ 403: { description: 'Verification email is not available', content: { 'application/json': { schema: errorResponseSchema } } },
3922
+ 429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
3923
+ },
3924
+ });
3925
+
3926
+ authRoute.openapi(requestEmailVerification, async (c) => {
3927
+ const userId = requireAuth(c.get('auth'));
3928
+ const body = await c.req.json<{
3929
+ redirectUrl?: string;
3930
+ state?: string;
3931
+ }>().catch(() => ({}));
3932
+ const redirect = parseClientRedirectInput(c.env, body);
3933
+ await ensureAuthActionAllowed(c, 'verifyEmail', { userId });
3934
+
3935
+ const db = getAuthDb(c);
3936
+ const user = await authService.getUserById(db, userId);
3937
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
3938
+ if (!user.email) throw new EdgeBaseError(400, 'Current user has no email address.', undefined, 'invalid-input');
3939
+ if (Number(user.isAnonymous) === 1) throw new EdgeBaseError(403, 'Anonymous users cannot request verification email.', undefined, 'anonymous-not-allowed');
3940
+ if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
3941
+
3942
+ const rateKey = `verify-email-rate:${userId}`;
3943
+ if (!counter.check(rateKey, 3, 3600)) {
3944
+ throw new EdgeBaseError(429, 'Too many verification email requests. Try again later.', undefined, 'rate-limited');
3945
+ }
3946
+
3947
+ await authService.deleteEmailTokensByUserAndType(db, userId, 'verify');
3948
+
3949
+ const token = crypto.randomUUID();
3950
+ const expiresInHours = 24;
3951
+ const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString();
3952
+ await authService.createEmailToken(db, {
3953
+ token,
3954
+ userId,
3955
+ type: 'verify',
3956
+ expiresAt,
3957
+ });
3958
+
3959
+ const emailConfig = getEmailConfig(c.env);
3960
+ const fallbackVerifyUrl = emailConfig?.verifyUrl
3961
+ ? emailConfig.verifyUrl.replace('{token}', token)
3962
+ : `#verify-email?token=${token}`;
3963
+ const verifyUrl = buildEmailActionUrl({
3964
+ redirectUrl: redirect.redirectUrl,
3965
+ fallbackUrl: fallbackVerifyUrl,
3966
+ token,
3967
+ type: 'verify',
3968
+ state: redirect.state,
3969
+ });
3970
+
3971
+ const provider = createEmailProvider(getEmailConfig(c.env), c.env);
3972
+ if (!provider) {
3973
+ const release = parseConfig(c.env)?.release ?? false;
3974
+ if (!release) {
3975
+ console.warn('[VerifyEmail] Email provider not configured. Verification email not sent. Token:', token);
3976
+ return c.json({ ok: true, message: 'Email provider not configured.', token, actionUrl: verifyUrl });
3977
+ }
3978
+ return c.json({ ok: true, message: 'Email provider not configured.' });
3979
+ }
3980
+
3981
+ const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
3982
+ const html = renderVerifyEmail({
3983
+ appName: getAppName(c.env),
3984
+ verifyUrl,
3985
+ token,
3986
+ expiresInHours,
3987
+ }, resolveLocalizedString(getEmailTemplates(c.env)?.verification, locale), locale);
3988
+
3989
+ const defaultSubject = getDefaultSubject(locale, 'verification').replace(/\{\{appName\}\}/g, getAppName(c.env));
3990
+ const result = await sendMailWithHook(
3991
+ c.env, c.executionCtx, provider, 'verification', user.email as string,
3992
+ resolveSubject(c.env, 'verification', defaultSubject, locale), html, locale,
3993
+ );
3994
+
3995
+ return c.json({ ok: result.success, messageId: result.messageId });
3996
+ });
3997
+
3998
+ // POST /verify-email — KV token→shardId lookup → direct Shard call
3999
+ const verifyEmail = createRoute({
4000
+ operationId: 'authVerifyEmail',
4001
+ method: 'post',
4002
+ path: '/verify-email',
4003
+ tags: ['client'],
4004
+ summary: 'Verify email address with token',
4005
+ request: {
4006
+ body: { content: { 'application/json': { schema: z.object({
4007
+ token: z.string(),
4008
+ }).passthrough() } }, required: true },
4009
+ },
4010
+ responses: {
4011
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
4012
+ 400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
4013
+ },
4014
+ });
4015
+
4016
+ authRoute.openapi(verifyEmail, async (c) => {
4017
+ const body = await c.req.json<{ token: string }>();
4018
+ if (!body.token) throw new EdgeBaseError(400, 'Verification token is required.', undefined, 'invalid-input');
4019
+
4020
+ await ensureAuthActionAllowed(c, 'verifyEmail', body as unknown as Record<string, unknown>);
4021
+
4022
+ const db = getAuthDb(c);
4023
+
4024
+ // Look up token directly in D1
4025
+ const row = await authService.getEmailTokenByType(db, body.token, 'verify');
4026
+ if (!row) throw new EdgeBaseError(400, 'Invalid or expired verification token.', undefined, 'invalid-token');
4027
+
4028
+ if (new Date(row.expiresAt as string) < new Date()) {
4029
+ await authService.deleteEmailToken(db, body.token);
4030
+ throw new EdgeBaseError(400, 'Verification token has expired. Please request a new one.', undefined, 'token-expired');
4031
+ }
4032
+
4033
+ const userId = row.userId as string;
4034
+
4035
+ await authService.updateUser(db, userId, { verified: 1 });
4036
+ await authService.deleteEmailTokensByUserAndType(db, userId, 'verify');
4037
+
4038
+ // onEmailVerified hook -- non-blocking
4039
+ const verifiedUser = await authService.getUserById(db, userId);
4040
+ if (verifiedUser) {
4041
+ c.executionCtx.waitUntil(
4042
+ executeAuthHook(c.env, c.executionCtx, 'onEmailVerified', authService.sanitizeUser(verifiedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
4043
+ );
4044
+ }
4045
+
4046
+ return c.json({ ok: true, message: 'Email verified' });
4047
+ });
4048
+
4049
+ // POST /request-password-reset — D1 email lookup → Shard
4050
+ const requestPasswordReset = createRoute({
4051
+ operationId: 'authRequestPasswordReset',
4052
+ method: 'post',
4053
+ path: '/request-password-reset',
4054
+ tags: ['client'],
4055
+ summary: 'Request password reset email',
4056
+ request: {
4057
+ body: { content: { 'application/json': { schema: z.object({
4058
+ email: z.string(),
4059
+ }).passthrough() } }, required: true },
4060
+ },
4061
+ responses: {
4062
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
4063
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
4064
+ },
4065
+ });
4066
+
4067
+ authRoute.openapi(requestPasswordReset, async (c) => {
4068
+ const body = await c.req.json<{
4069
+ email: string;
4070
+ redirectUrl?: string;
4071
+ state?: string;
4072
+ }>();
4073
+ if (!body.email) throw new EdgeBaseError(400, 'Email is required.', undefined, 'invalid-input');
4074
+ body.email = body.email.trim().toLowerCase();
4075
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
4076
+ throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
4077
+ }
4078
+ const redirect = parseClientRedirectInput(c.env, body);
4079
+
4080
+ await ensureAuthActionAllowed(c, 'requestPasswordReset', body as unknown as Record<string, unknown>);
4081
+
4082
+ const db = getAuthDb(c);
4083
+
4084
+ // Look up email in D1
4085
+ const record = await lookupEmail(db, body.email);
4086
+
4087
+ if (!record) {
4088
+ // Don't reveal whether email exists -- return ok
4089
+ return c.json({ ok: true, message: 'If the email exists, a reset link has been sent.' });
4090
+ }
4091
+
4092
+ const { userId } = record;
4093
+ const user = await authService.getUserById(db, userId);
4094
+ if (!user || !user.email) {
4095
+ return c.json({ ok: true, message: 'If the email exists, a reset link has been sent.' });
4096
+ }
4097
+
4098
+ // Delete old reset tokens for this user
4099
+ await authService.deleteEmailTokensByUserAndType(db, userId, 'password-reset');
4100
+
4101
+ const token = crypto.randomUUID();
4102
+ const now = new Date();
4103
+ const expiresAt = new Date(now.getTime() + 60 * 60 * 1000); // 1h
4104
+
4105
+ await authService.createEmailToken(db, {
4106
+ token,
4107
+ userId,
4108
+ type: 'password-reset',
4109
+ expiresAt: expiresAt.toISOString(),
4110
+ });
4111
+
4112
+ const emailConfig = getEmailConfig(c.env);
4113
+ const fallbackResetUrl = emailConfig?.resetUrl
4114
+ ? emailConfig.resetUrl.replace('{token}', token)
4115
+ : `#reset-password?token=${token}`;
4116
+ const resetUrl = buildEmailActionUrl({
4117
+ redirectUrl: redirect.redirectUrl,
4118
+ fallbackUrl: fallbackResetUrl,
4119
+ token,
4120
+ type: 'password-reset',
4121
+ state: redirect.state,
4122
+ });
4123
+
4124
+ const provider = createEmailProvider(getEmailConfig(c.env), c.env);
4125
+ if (!provider) {
4126
+ const release = parseConfig(c.env)?.release ?? false;
4127
+ if (!release) {
4128
+ console.warn('[Auth] Email provider not configured. Reset email not sent. Token:', token);
4129
+ return c.json({ ok: true, message: 'Email provider not configured.', token, actionUrl: resetUrl });
4130
+ }
4131
+ return c.json({ ok: true, message: 'Email provider not configured.' });
4132
+ }
4133
+
4134
+ const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
4135
+ const html = renderPasswordReset({
4136
+ appName: getAppName(c.env),
4137
+ resetUrl,
4138
+ token,
4139
+ expiresInMinutes: 60,
4140
+ }, resolveLocalizedString(getEmailTemplates(c.env)?.passwordReset, locale), locale);
4141
+
4142
+ const defaultSubject = getDefaultSubject(locale, 'passwordReset').replace(/\{\{appName\}\}/g, getAppName(c.env));
4143
+ const result = await sendMailWithHook(
4144
+ c.env, c.executionCtx, provider, 'passwordReset', user.email as string,
4145
+ resolveSubject(c.env, 'passwordReset', defaultSubject, locale), html, locale,
4146
+ );
4147
+
4148
+ return c.json({ ok: result.success, messageId: result.messageId });
4149
+ });
4150
+
4151
+ // POST /reset-password — KV token→shardId lookup → direct Shard call
4152
+ const resetPassword = createRoute({
4153
+ operationId: 'authResetPassword',
4154
+ method: 'post',
4155
+ path: '/reset-password',
4156
+ tags: ['client'],
4157
+ summary: 'Reset password with token',
4158
+ request: {
4159
+ body: { content: { 'application/json': { schema: z.object({
4160
+ token: z.string(),
4161
+ newPassword: z.string(),
4162
+ }).passthrough() } }, required: true },
4163
+ },
4164
+ responses: {
4165
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
4166
+ 400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
4167
+ 403: { description: 'Blocked by hook', content: { 'application/json': { schema: errorResponseSchema } } },
4168
+ },
4169
+ });
4170
+
4171
+ authRoute.openapi(resetPassword, async (c) => {
4172
+ const body = await c.req.json<{ token: string; newPassword: string }>();
4173
+ if (!body.token) throw new EdgeBaseError(400, 'Password reset token is required.', undefined, 'invalid-input');
4174
+ if (!body.newPassword) throw new EdgeBaseError(400, 'New password is required.', undefined, 'invalid-input');
4175
+
4176
+ await ensureAuthActionAllowed(c, 'resetPassword', {
4177
+ token: body.token,
4178
+ newPassword: body.newPassword,
4179
+ });
4180
+
4181
+ const db = getAuthDb(c);
4182
+
4183
+ // Password policy validation
4184
+ const policyResult = await validatePassword(body.newPassword, getPasswordPolicyConfig(c.env));
4185
+ if (!policyResult.valid) {
4186
+ throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
4187
+ }
4188
+
4189
+ // Look up token in D1
4190
+ const row = await authService.getEmailTokenByType(db, body.token, 'password-reset');
4191
+ if (!row) throw new EdgeBaseError(400, 'Invalid or expired password reset token.', undefined, 'invalid-token');
4192
+
4193
+ if (new Date(row.expiresAt as string) < new Date()) {
4194
+ await authService.deleteEmailToken(db, body.token);
4195
+ throw new EdgeBaseError(400, 'Password reset token has expired. Please request a new one.', undefined, 'token-expired');
4196
+ }
4197
+
4198
+ const userId = row.userId as string;
4199
+
4200
+ await executeAuthHook(c.env, c.executionCtx, 'beforePasswordReset', { userId }, {
4201
+ blocking: true,
4202
+ workerUrl: getWorkerUrl(c.req.url, c.env),
4203
+ });
4204
+
4205
+ const newHash = await hashPassword(body.newPassword);
4206
+ await authService.updateUser(db, userId, { passwordHash: newHash });
4207
+
4208
+ // afterPasswordReset hook -- non-blocking
4209
+ const resetUser = await authService.getUserById(db, userId);
4210
+ if (resetUser) {
4211
+ c.executionCtx.waitUntil(
4212
+ executeAuthHook(c.env, c.executionCtx, 'afterPasswordReset', authService.sanitizeUser(resetUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
4213
+ );
4214
+ }
4215
+
4216
+ // Revoke all sessions (force re-login)
4217
+ await authService.deleteAllUserSessions(db, userId);
4218
+ // Delete all reset tokens
4219
+ await authService.deleteEmailTokensByUserAndType(db, userId, 'password-reset');
4220
+
4221
+ return c.json({ ok: true, message: 'Password reset. All sessions revoked.' });
4222
+ });