@edge-base/server 0.2.9 → 0.3.2

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 (89) hide show
  1. package/admin-build/_app/immutable/chunks/BENT96gS.js +1 -0
  2. package/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BR-X1vqg.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BYTXdmFz.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → C-_7bS5w.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → CCB6XzFZ.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{39YMyjjq.js → CWTeyOdB.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{DllVADtt.js → CX5CmbTG.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{0M5txo3l.js → CdgSuiqt.js} +3 -3
  9. package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Cuny2nPU.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → D15DP4DQ.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{DBkor_jM.js → DAzRCtch.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/DYF4byEY.js +1 -0
  13. package/admin-build/_app/immutable/chunks/{DBniE7GN.js → Dcey5WNo.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{C5nMidnK.js → HK54RDdv.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → gUtOgqgM.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{_xa30BOD.js → pnqPwnFK.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.DwslmBQo.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Bi51W3Uj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.SSaZIC3t.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.CqSv9lq5.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.BoQz_717.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.B-yS_gft.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.WsVtLoVx.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.TbqRQJKh.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.cldePwj3.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.Bec037-f.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.Dd0yTYhN.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.CoiqH0lF.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.BDXxM1Qi.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.lo9mTPiR.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.B7kbGqh_.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.BGm1TcfO.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.VOtCuTkf.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.C2Uxx-kO.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.BxdEwsS3.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.CVu2qoCv.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.DqYlpX7Z.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.emgbpP8y.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.BVPSPK68.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.BsDQQ16z.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.uc8joqGb.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.D4CYqQrE.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.DvUuXrVU.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.BqTZ0Uoq.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.DAO3--6W.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.CBbGl_Wo.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.s-I8cKAh.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.Ct6B-y3f.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.Dok_ZoeZ.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +498 -3
  53. package/package.json +3 -3
  54. package/src/__tests__/auth-token.test.ts +142 -0
  55. package/src/__tests__/email-provider.test.ts +82 -0
  56. package/src/__tests__/error-format.test.ts +21 -0
  57. package/src/__tests__/functions-context.test.ts +87 -0
  58. package/src/__tests__/functions-d1-proxy.test.ts +70 -0
  59. package/src/__tests__/functions-route.test.ts +15 -0
  60. package/src/__tests__/postgres-api-consistency.test.ts +240 -0
  61. package/src/__tests__/postgres-batch-compat.test.ts +33 -27
  62. package/src/__tests__/postgres-batch.test.ts +394 -0
  63. package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
  64. package/src/__tests__/postgres-transact.test.ts +148 -0
  65. package/src/__tests__/room-access-policy.test.ts +26 -0
  66. package/src/__tests__/room-handler-context.test.ts +2 -0
  67. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  68. package/src/durable-objects/database-do.ts +455 -15
  69. package/src/durable-objects/room-runtime-base.ts +16 -5
  70. package/src/durable-objects/rooms-do.ts +4 -1
  71. package/src/lib/auth-d1-service.ts +37 -7
  72. package/src/lib/auth-token.ts +66 -0
  73. package/src/lib/d1-handler.ts +416 -50
  74. package/src/lib/email-provider.ts +115 -5
  75. package/src/lib/errors.ts +8 -4
  76. package/src/lib/functions.ts +91 -1
  77. package/src/lib/internal-transport.ts +25 -1
  78. package/src/lib/jwt.ts +2 -0
  79. package/src/lib/postgres-handler.ts +562 -84
  80. package/src/routes/admin.ts +8 -1
  81. package/src/routes/auth.ts +169 -65
  82. package/src/routes/room.ts +36 -2
  83. package/src/routes/storage.ts +446 -62
  84. package/src/routes/tables.ts +140 -0
  85. package/src/types.ts +10 -0
  86. package/admin-build/_app/immutable/chunks/B0zJ5_Wk.js +0 -1
  87. package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
  88. package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
  89. package/admin-build/_app/immutable/nodes/21.CzGYxjpu.js +0 -1
@@ -1539,7 +1539,14 @@ api.openapi(adminCreateSignedUrl, async (c) => {
1539
1539
 
1540
1540
  const expiresInMs = parseDuration(body.expiresIn || '1h');
1541
1541
  const expiresAt = Date.now() + expiresInMs;
1542
- const token = await createSignedToken(body.key, bucketName, expiresAt, secret);
1542
+ const token = await createSignedToken(body.key, bucketName, expiresAt, secret, {
1543
+ file: {
1544
+ size: obj.size,
1545
+ contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
1546
+ etag: obj.etag,
1547
+ uploadedAt: obj.uploaded?.toISOString(),
1548
+ },
1549
+ });
1543
1550
 
1544
1551
  // Build signed URL using the public storage endpoint
1545
1552
  const url = new URL(c.req.url);
@@ -27,6 +27,7 @@ import {
27
27
  buildEmailActionUrl,
28
28
  parseClientRedirectInput,
29
29
  } from '../lib/auth-redirect.js';
30
+ import { hashOtpSecret, verifyOtpSecret } from '../lib/auth-token.js';
30
31
  import {
31
32
  signAccessToken, signRefreshToken, verifyRefreshTokenWithFallback,
32
33
  parseDuration, TokenExpiredError,
@@ -176,7 +177,7 @@ function getMaxActiveSessions(env: Env): number {
176
177
  }
177
178
 
178
179
  function isEmailProviderName(value: unknown): value is EmailConfig['provider'] {
179
- return value === 'resend' || value === 'sendgrid' || value === 'mailgun' || value === 'ses';
180
+ return value === 'resend' || value === 'sendgrid' || value === 'mailgun' || value === 'ses' || value === 'cloudflare';
180
181
  }
181
182
 
182
183
  function getEmailConfig(env: Env): EmailConfig | undefined {
@@ -187,22 +188,29 @@ function getEmailConfig(env: Env): EmailConfig | undefined {
187
188
  const provider = configured?.provider
188
189
  ?? (isEmailProviderName(runtimeEnv.EDGEBASE_EMAIL_PROVIDER) ? runtimeEnv.EDGEBASE_EMAIL_PROVIDER : undefined);
189
190
  const apiKey = configured?.apiKey
190
- ?? (typeof runtimeEnv.EDGEBASE_EMAIL_API_KEY === 'string' ? runtimeEnv.EDGEBASE_EMAIL_API_KEY : undefined);
191
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_API_KEY === 'string' ? runtimeEnv.EDGEBASE_EMAIL_API_KEY : undefined)
192
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_API_TOKEN === 'string' ? runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_API_TOKEN : undefined);
191
193
  const from = configured?.from
192
194
  ?? (typeof runtimeEnv.EDGEBASE_EMAIL_FROM === 'string' ? runtimeEnv.EDGEBASE_EMAIL_FROM : undefined);
195
+ const cloudflareAccountId = configured?.accountId
196
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_ACCOUNT_ID === 'string' ? runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_ACCOUNT_ID : undefined);
197
+ const cloudflareBinding = configured?.binding
198
+ ?? (typeof runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_BINDING === 'string' ? runtimeEnv.EDGEBASE_EMAIL_CLOUDFLARE_BINDING : undefined);
193
199
 
194
- if (!provider || !apiKey || !from) {
200
+ if (!provider || !from || (provider !== 'cloudflare' && !apiKey)) {
195
201
  return configured;
196
202
  }
197
203
 
198
204
  return {
199
205
  provider,
200
- apiKey,
201
206
  from,
207
+ ...(apiKey ? { apiKey } : {}),
202
208
  domain: configured?.domain
203
209
  ?? (typeof runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN === 'string' ? runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN : undefined),
204
210
  region: configured?.region
205
211
  ?? (typeof runtimeEnv.EDGEBASE_EMAIL_SES_REGION === 'string' ? runtimeEnv.EDGEBASE_EMAIL_SES_REGION : undefined),
212
+ accountId: cloudflareAccountId,
213
+ binding: cloudflareBinding,
206
214
  appName: configured?.appName ?? 'EdgeBase Local Auth Harness',
207
215
  defaultLocale: configured?.defaultLocale,
208
216
  verifyUrl: configured?.verifyUrl
@@ -539,7 +547,7 @@ async function rotateRefreshTokenFlow(
539
547
  }
540
548
 
541
549
  const newRefreshToken = await signRefreshToken(
542
- { sub: userId, type: 'refresh', jti: generateId() },
550
+ { sub: userId, type: 'refresh', jti: session.id as string },
543
551
  getUserSecret(env),
544
552
  getRefreshTokenTTL(env),
545
553
  );
@@ -781,7 +789,11 @@ async function sendMailWithHook(
781
789
  }
782
790
  }
783
791
 
784
- return provider.send({ to, subject: finalSubject, html: finalHtml });
792
+ const result = await provider.send({ to, subject: finalSubject, html: finalHtml });
793
+ if (!result.success) {
794
+ throw new EdgeBaseError(502, 'Email delivery failed.', undefined, 'email-delivery-failed');
795
+ }
796
+ return result;
785
797
  }
786
798
 
787
799
  async function sendSmsWithHook(
@@ -987,15 +999,8 @@ authRoute.openapi(signup, async (c) => {
987
999
 
988
1000
  const userId = generateId();
989
1001
  const db = getAuthDb(c);
990
-
991
- // Register pending in D1 email index
992
- try {
993
- await registerEmailPending(db, body.email, userId);
994
- } catch (err) {
995
- if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
996
- throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
997
- }
998
- throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
1002
+ if (await lookupEmail(db, body.email)) {
1003
+ throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
999
1004
  }
1000
1005
 
1001
1006
  // Create user directly in D1
@@ -1003,10 +1008,27 @@ authRoute.openapi(signup, async (c) => {
1003
1008
  // Password policy validation
1004
1009
  const policyResult = await validatePassword(body.password, getPasswordPolicyConfig(c.env));
1005
1010
  if (!policyResult.valid) {
1006
- await deleteEmailPending(db, body.email).catch(() => {});
1007
1011
  throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
1008
1012
  }
1009
1013
 
1014
+ // beforeSignUp hook — blocking, can cancel signup
1015
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1016
+ id: userId,
1017
+ email: body.email,
1018
+ displayName,
1019
+ avatarUrl,
1020
+ }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1021
+
1022
+ // Register pending in D1 email index
1023
+ try {
1024
+ await registerEmailPending(db, body.email, userId);
1025
+ } catch (err) {
1026
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
1027
+ throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
1028
+ }
1029
+ throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
1030
+ }
1031
+
1010
1032
  const passwordHash = await hashPassword(body.password);
1011
1033
 
1012
1034
  await authService.createUser(db, {
@@ -1021,14 +1043,6 @@ authRoute.openapi(signup, async (c) => {
1021
1043
  locale,
1022
1044
  });
1023
1045
 
1024
- // beforeSignUp hook — blocking, can cancel signup
1025
- await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1026
- id: userId,
1027
- email: body.email,
1028
- displayName,
1029
- avatarUrl,
1030
- }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1031
-
1032
1046
  // Create session + tokens
1033
1047
  const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
1034
1048
 
@@ -1370,19 +1384,24 @@ authRoute.openapi(signinMagicLink, async (c) => {
1370
1384
  } else if (autoCreate) {
1371
1385
  // Auto-create user + send magic link
1372
1386
  const userId = generateId();
1387
+ const reqLocale = parseAcceptLanguage(c.req.header('accept-language'));
1373
1388
 
1374
1389
  try {
1375
- await registerEmailPending(db, body.email, userId);
1376
- } catch (err) {
1377
- if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
1378
- return c.json({ ok: true });
1390
+ // beforeSignUp hook
1391
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1392
+ id: userId, email: body.email, displayName: null, avatarUrl: null,
1393
+ }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1394
+
1395
+ try {
1396
+ await registerEmailPending(db, body.email, userId);
1397
+ } catch (err) {
1398
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
1399
+ return c.json({ ok: true });
1400
+ }
1401
+ throw new EdgeBaseError(500, 'Magic link request failed.', undefined, 'internal-error');
1379
1402
  }
1380
- throw new EdgeBaseError(500, 'Magic link request failed.', undefined, 'internal-error');
1381
- }
1382
1403
 
1383
- try {
1384
1404
  // Create user with no password, verified = 1
1385
- const reqLocale = parseAcceptLanguage(c.req.header('accept-language'));
1386
1405
  await authService.createUser(db, {
1387
1406
  userId,
1388
1407
  email: body.email,
@@ -1393,11 +1412,6 @@ authRoute.openapi(signinMagicLink, async (c) => {
1393
1412
  locale: reqLocale ?? 'en',
1394
1413
  });
1395
1414
 
1396
- // beforeSignUp hook
1397
- await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1398
- id: userId, email: body.email, displayName: null, avatarUrl: null,
1399
- }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1400
-
1401
1415
  // Sync to _users_public
1402
1416
  const user = await authService.getUserById(db, userId);
1403
1417
  if (user) {
@@ -1452,7 +1466,7 @@ authRoute.openapi(signinMagicLink, async (c) => {
1452
1466
  await sendMailWithHook(
1453
1467
  c.env, c.executionCtx, provider, 'magicLink', body.email,
1454
1468
  resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
1455
- ).catch(() => {});
1469
+ );
1456
1470
  } else {
1457
1471
  if (!isReleaseMode(c.env)) {
1458
1472
  debugToken = token;
@@ -1627,10 +1641,12 @@ authRoute.openapi(signinPhone, async (c) => {
1627
1641
  const code = generateOTP();
1628
1642
  if (exposeTestSecrets) devCode = code;
1629
1643
 
1644
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
1645
+
1630
1646
  // Store OTP in KV with 5 min TTL
1631
1647
  await c.env.KV.put(
1632
1648
  `phone-otp:${phone}`,
1633
- JSON.stringify({ code, userId, attempts: 0 }),
1649
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
1634
1650
  { expirationTtl: 300 },
1635
1651
  );
1636
1652
 
@@ -1657,15 +1673,20 @@ authRoute.openapi(signinPhone, async (c) => {
1657
1673
  const userId = generateId();
1658
1674
 
1659
1675
  try {
1660
- await registerPhonePending(db, phone, userId);
1661
- } catch (err) {
1662
- if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
1663
- return c.json({ ok: true });
1676
+ // beforeSignUp hook
1677
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
1678
+ id: userId, email: null, phone, displayName: null, avatarUrl: null,
1679
+ }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
1680
+
1681
+ try {
1682
+ await registerPhonePending(db, phone, userId);
1683
+ } catch (err) {
1684
+ if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
1685
+ return c.json({ ok: true });
1686
+ }
1687
+ throw new EdgeBaseError(500, 'Phone OTP request failed.', undefined, 'internal-error');
1664
1688
  }
1665
- throw new EdgeBaseError(500, 'Phone OTP request failed.', undefined, 'internal-error');
1666
- }
1667
1689
 
1668
- try {
1669
1690
  // Create user with phone in D1
1670
1691
  await authService.createUser(db, {
1671
1692
  userId,
@@ -1679,10 +1700,12 @@ authRoute.openapi(signinPhone, async (c) => {
1679
1700
  const code = generateOTP();
1680
1701
  if (exposeTestSecrets) devCode = code;
1681
1702
 
1703
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
1704
+
1682
1705
  // Store OTP in KV
1683
1706
  await c.env.KV.put(
1684
1707
  `phone-otp:${phone}`,
1685
- JSON.stringify({ code, userId, attempts: 0 }),
1708
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
1686
1709
  { expirationTtl: 300 },
1687
1710
  );
1688
1711
 
@@ -1756,7 +1779,7 @@ authRoute.openapi(verifyPhone, async (c) => {
1756
1779
 
1757
1780
  // Look up phone → userId via KV OTP data
1758
1781
  const otpData = await c.env.KV.get(`phone-otp:${phone}`, 'json') as {
1759
- code: string; userId: string; attempts: number;
1782
+ codeHash?: string; code?: string; userId: string; attempts: number;
1760
1783
  } | null;
1761
1784
 
1762
1785
  if (!otpData) {
@@ -1769,8 +1792,11 @@ authRoute.openapi(verifyPhone, async (c) => {
1769
1792
  throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
1770
1793
  }
1771
1794
 
1772
- // Verify code (timing-safe comparison)
1773
- if (!timingSafeEqual(otpData.code, body.code)) {
1795
+ const submittedCode = body.code.trim();
1796
+ const otpMatches = otpData.codeHash
1797
+ ? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
1798
+ : timingSafeEqual(otpData.code ?? '', submittedCode);
1799
+ if (!otpMatches) {
1774
1800
  await c.env.KV.put(
1775
1801
  `phone-otp:${phone}`,
1776
1802
  JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
@@ -1895,10 +1921,12 @@ authRoute.openapi(linkPhone, async (c) => {
1895
1921
  const code = generateOTP();
1896
1922
  const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
1897
1923
 
1924
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
1925
+
1898
1926
  // Store link OTP in KV (separate key pattern)
1899
1927
  await c.env.KV.put(
1900
1928
  `phone-link-otp:${phone}`,
1901
- JSON.stringify({ code, userId, attempts: 0 }),
1929
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
1902
1930
  { expirationTtl: 300 },
1903
1931
  );
1904
1932
 
@@ -1964,7 +1992,7 @@ authRoute.openapi(verifyLinkPhone, async (c) => {
1964
1992
 
1965
1993
  // Verify OTP from KV
1966
1994
  const otpData = await c.env.KV.get(`phone-link-otp:${phone}`, 'json') as {
1967
- code: string; userId: string; attempts: number;
1995
+ codeHash?: string; code?: string; userId: string; attempts: number;
1968
1996
  } | null;
1969
1997
 
1970
1998
  if (!otpData) {
@@ -1980,7 +2008,11 @@ authRoute.openapi(verifyLinkPhone, async (c) => {
1980
2008
  throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
1981
2009
  }
1982
2010
 
1983
- if (!timingSafeEqual(otpData.code, body.code)) {
2011
+ const submittedCode = body.code.trim();
2012
+ const otpMatches = otpData.codeHash
2013
+ ? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
2014
+ : timingSafeEqual(otpData.code ?? '', submittedCode);
2015
+ if (!otpMatches) {
1984
2016
  await c.env.KV.put(
1985
2017
  `phone-link-otp:${phone}`,
1986
2018
  JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
@@ -2083,11 +2115,12 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2083
2115
 
2084
2116
  const code = generateOTP();
2085
2117
  if (exposeTestSecrets) devCode = code;
2118
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
2086
2119
 
2087
2120
  // Store OTP in KV with 5 min TTL
2088
2121
  await c.env.KV.put(
2089
2122
  `email-otp:${email}`,
2090
- JSON.stringify({ code, userId, attempts: 0 }),
2123
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
2091
2124
  { expirationTtl: 300 },
2092
2125
  );
2093
2126
 
@@ -2117,19 +2150,24 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2117
2150
  }
2118
2151
 
2119
2152
  const userId = generateId();
2153
+ const otpReqLocale = parseAcceptLanguage(c.req.header('accept-language'));
2120
2154
 
2121
2155
  try {
2122
- await registerEmailPending(db, email, userId);
2123
- } catch (err) {
2124
- if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
2125
- return c.json({ ok: true });
2156
+ // beforeSignUp hook
2157
+ await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
2158
+ id: userId, email, displayName: null, avatarUrl: null,
2159
+ }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
2160
+
2161
+ try {
2162
+ await registerEmailPending(db, email, userId);
2163
+ } catch (err) {
2164
+ if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
2165
+ return c.json({ ok: true });
2166
+ }
2167
+ throw new EdgeBaseError(500, 'Email OTP request failed.', undefined, 'internal-error');
2126
2168
  }
2127
- throw new EdgeBaseError(500, 'Email OTP request failed.', undefined, 'internal-error');
2128
- }
2129
2169
 
2130
- try {
2131
2170
  // Create user with email, verified = 1
2132
- const otpReqLocale = parseAcceptLanguage(c.req.header('accept-language'));
2133
2171
  await authService.createUser(db, {
2134
2172
  userId,
2135
2173
  email,
@@ -2141,11 +2179,12 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2141
2179
 
2142
2180
  const code = generateOTP();
2143
2181
  if (exposeTestSecrets) devCode = code;
2182
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
2144
2183
 
2145
2184
  // Store OTP in KV
2146
2185
  await c.env.KV.put(
2147
2186
  `email-otp:${email}`,
2148
- JSON.stringify({ code, userId, attempts: 0 }),
2187
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
2149
2188
  { expirationTtl: 300 },
2150
2189
  );
2151
2190
 
@@ -2218,7 +2257,7 @@ authRoute.openapi(verifyEmailOtp, async (c) => {
2218
2257
 
2219
2258
  // Look up OTP data from KV
2220
2259
  const otpData = await c.env.KV.get(`email-otp:${email}`, 'json') as {
2221
- code: string; userId: string; attempts: number;
2260
+ codeHash?: string; code?: string; userId: string; attempts: number;
2222
2261
  } | null;
2223
2262
 
2224
2263
  if (!otpData) {
@@ -2230,7 +2269,11 @@ authRoute.openapi(verifyEmailOtp, async (c) => {
2230
2269
  throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
2231
2270
  }
2232
2271
 
2233
- if (!timingSafeEqual(otpData.code, body.code)) {
2272
+ const submittedCode = body.code.trim();
2273
+ const otpMatches = otpData.codeHash
2274
+ ? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
2275
+ : timingSafeEqual(otpData.code ?? '', submittedCode);
2276
+ if (!otpMatches) {
2234
2277
  await c.env.KV.put(
2235
2278
  `email-otp:${email}`,
2236
2279
  JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
@@ -2577,6 +2620,67 @@ authRoute.openapi(mfaRecovery, async (c) => {
2577
2620
  });
2578
2621
  });
2579
2622
 
2623
+ // POST /mfa/recovery-codes — regenerate recovery codes (authenticated)
2624
+ const mfaRecoveryCodesRegenerate = createRoute({
2625
+ operationId: 'authMfaRecoveryCodesRegenerate',
2626
+ method: 'post',
2627
+ path: '/mfa/recovery-codes',
2628
+ tags: ['client'],
2629
+ summary: 'Regenerate MFA recovery codes',
2630
+ request: {
2631
+ body: { content: { 'application/json': { schema: z.object({
2632
+ password: z.string().optional(),
2633
+ code: z.string().optional(),
2634
+ }).passthrough() } }, required: false },
2635
+ },
2636
+ responses: {
2637
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2638
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2639
+ 401: { description: 'Authentication required or verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
2640
+ },
2641
+ });
2642
+
2643
+ authRoute.openapi(mfaRecoveryCodesRegenerate, async (c) => {
2644
+ const userId = requireAuth(c.get('auth'));
2645
+ const bodyText = await c.req.text();
2646
+ let body: { password?: string; code?: string } = {};
2647
+ try { body = bodyText ? JSON.parse(bodyText) : {}; } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
2648
+ await ensureAuthActionAllowed(c, 'mfaRecoveryCodesRegenerate', {
2649
+ userId,
2650
+ passwordProvided: !!body.password,
2651
+ codeProvided: !!body.code,
2652
+ });
2653
+ const db = getAuthDb(c);
2654
+
2655
+ const user = await authService.getUserById(db, userId);
2656
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2657
+ const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
2658
+ if (!factor || !factor.verified) {
2659
+ throw new EdgeBaseError(400, 'No verified TOTP factor found.', undefined, 'invalid-input');
2660
+ }
2661
+
2662
+ if (body.password) {
2663
+ if (!user.passwordHash) throw new EdgeBaseError(400, 'This account has no password set.', undefined, 'invalid-input');
2664
+ const valid = await verifyPassword(body.password, user.passwordHash as string);
2665
+ if (!valid) throw new EdgeBaseError(401, 'Invalid password.', undefined, 'invalid-password');
2666
+ } else if (body.code) {
2667
+ const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
2668
+ const valid = await verifyTOTP(secret, body.code);
2669
+ if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
2670
+ } else {
2671
+ throw new EdgeBaseError(400, 'Either password or TOTP code is required to regenerate recovery codes.', undefined, 'invalid-input');
2672
+ }
2673
+
2674
+ const recoveryCodes = generateRecoveryCodes(8);
2675
+ const hashedCodes: { id: string; codeHash: string }[] = [];
2676
+ for (const code of recoveryCodes) {
2677
+ hashedCodes.push({ id: generateId(), codeHash: await hashRecoveryCode(code) });
2678
+ }
2679
+ await authService.replaceRecoveryCodes(db, userId, hashedCodes);
2680
+
2681
+ return c.json({ recoveryCodes });
2682
+ });
2683
+
2580
2684
  // DELETE /mfa/totp — disable TOTP (authenticated)
2581
2685
  const mfaTotpDelete = createRoute({
2582
2686
  operationId: 'authMfaTotpDelete',
@@ -12,11 +12,13 @@
12
12
  * - KV counter with expirationTtl: 60s
13
13
  */
14
14
  import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
15
- import type { RoomNamespaceConfig } from '@edge-base/shared';
15
+ import type { EdgeBaseConfig, RoomHandlerContext, RoomNamespaceConfig } from '@edge-base/shared';
16
16
  import type { Context } from 'hono';
17
17
  import { parseConfig } from '../lib/do-router.js';
18
+ import { buildFunctionContext } from '../lib/functions.js';
18
19
  import { resolveRoomRuntime } from '../lib/room-runtime.js';
19
20
  import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
21
+ import { resolveRootServiceKey } from '../lib/service-key.js';
20
22
  import {
21
23
  acquirePendingWebSocketSlot,
22
24
  getPendingWebSocketCount,
@@ -111,6 +113,33 @@ function getRoomAuthContext(
111
113
  };
112
114
  }
113
115
 
116
+ function buildRoomRouteAccessContext(
117
+ c: Context<HonoEnv>,
118
+ config: EdgeBaseConfig,
119
+ ): RoomHandlerContext {
120
+ let executionCtx: ExecutionContext | undefined;
121
+ try {
122
+ executionCtx = c.executionCtx;
123
+ } catch {
124
+ executionCtx = undefined;
125
+ }
126
+ const ctx = buildFunctionContext({
127
+ request: c.req.raw,
128
+ auth: getRoomAuthContext(c.get('auth') as never),
129
+ databaseNamespace: c.env.DATABASE,
130
+ authNamespace: c.env.AUTH,
131
+ d1Database: c.env.AUTH_DB,
132
+ kvNamespace: c.env.KV,
133
+ config,
134
+ env: c.env,
135
+ executionCtx,
136
+ serviceKey: resolveRootServiceKey(config, c.env),
137
+ });
138
+ return {
139
+ admin: ctx.admin as never,
140
+ };
141
+ }
142
+
114
143
  export async function proxyRoomDoRequest(
115
144
  c: Context<HonoEnv>,
116
145
  path: string,
@@ -433,9 +462,11 @@ roomRoute.openapi(getRoomMetadata, async (c) => {
433
462
  }
434
463
  if (metadataAccess) {
435
464
  const auth = (c.get('auth') as { id?: string; role?: string; email?: string | null; custom?: Record<string, unknown> | null; isAnonymous?: boolean; meta?: Record<string, unknown> } | null | undefined) ?? null;
465
+ const accessCtx = buildRoomRouteAccessContext(c, config);
436
466
  const allowed = await Promise.resolve(metadataAccess(
437
467
  getRoomAuthContext(auth),
438
468
  roomId,
469
+ accessCtx,
439
470
  )).catch(() => false);
440
471
  if (!allowed) {
441
472
  return c.json({ code: 403, message: 'Denied by room metadata access rule' }, 403);
@@ -493,9 +524,11 @@ roomRoute.openapi(getRoomSummary, async (c) => {
493
524
  }
494
525
  if (metadataAccess) {
495
526
  const auth = (c.get('auth') as { id?: string; role?: string; email?: string | null; custom?: Record<string, unknown> | null; isAnonymous?: boolean; meta?: Record<string, unknown> } | null | undefined) ?? null;
527
+ const accessCtx = buildRoomRouteAccessContext(c, config);
496
528
  const allowed = await Promise.resolve(metadataAccess(
497
529
  getRoomAuthContext(auth),
498
530
  roomId,
531
+ accessCtx,
499
532
  )).catch(() => false);
500
533
  if (!allowed) {
501
534
  return c.json({ code: 403, message: 'Denied by room metadata access rule' }, 403);
@@ -557,8 +590,9 @@ roomRoute.openapi(getRoomSummaries, async (c) => {
557
590
  const deniedIds: string[] = [];
558
591
 
559
592
  if (metadataAccess) {
593
+ const accessCtx = buildRoomRouteAccessContext(c, config);
560
594
  for (const roomId of roomIds) {
561
- const allowed = await Promise.resolve(metadataAccess(authContext, roomId)).catch(() => false);
595
+ const allowed = await Promise.resolve(metadataAccess(authContext, roomId, accessCtx)).catch(() => false);
562
596
  if (allowed) {
563
597
  allowedRoomIds.push(roomId);
564
598
  } else {