@edge-base/server 0.2.8 → 0.3.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 (78) hide show
  1. package/admin-build/_app/immutable/chunks/{zl2AUKMP.js → BSIzoJ5I.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{j4jxnAKj.js → BeYswbvA.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DcVb45Ds.js → BkrC_6Ss.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{fPy6xmgG.js → BunyH6GE.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{B9efkx2V.js → C-zXt-cq.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{DYtrHeVQ.js → C1Mycuvd.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{Bt4AyT3o.js → Cek51lkE.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/{D8aeTKry.js → D0mNYcxN.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/D3hdqsfp.js +1 -0
  10. package/admin-build/_app/immutable/chunks/{DPgR4-0v.js → DF40xjpj.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{CTRjWhGs.js → DOOhHO-v.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/DdjFeYwG.js +1 -0
  13. package/admin-build/_app/immutable/chunks/{CwyE59Yt.js → Dvlxm2Iq.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BMXWUTG-.js → Xm1dHMTE.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{DGAHkap7.js → aPulNooa.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{CMYgGhZR.js → dZsKGnWZ.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.Cmz0WjMl.js → app.j1WHBlZX.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.y6D_QyUb.js → 0.Oo7ZpbR-.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.CndRxhbH.js → 1.zgMnKHns.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.CdA5FmXy.js → 10.u5mkRiUe.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.DG8SzMp_.js → 11.9ynAgaxV.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CvmQqpFa.js → 12.M_EqzJ6s.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.BbGNdswT.js → 13.D_NQ9q60.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.CZKsN7-O.js → 14.-vENIJ2w.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.A7-CYgkG.js → 15.DoMCZhGv.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.hgJT9H-x.js → 16.6BmnzScq.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.DkWZbcN2.js → 17.q6QQ9N_J.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.sX3Fb5gh.js → 18.DUhJ6dKj.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.VAZUW-1K.js → 19.CvkvPlGP.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.DkIKxacG.js → 20.cACSbRIl.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.BDaHvtaw.js → 22.BsQ6xeKr.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.BVRzw_pD.js → 23.CNW3mLz5.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CVhSJyG0.js → 24.CReqtEOy.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.Bme-9bZn.js → 25.BuxVhqlb.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.Dsx7RIIs.js → 26.C-LzPf62.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.DMGQnzFM.js → 27.Dl5RL_tB.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.GGwFmEhZ.js → 28.CWfSQgjH.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.Dnghr0nk.js → 29.wUSfh2eQ.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.Cg7zZJP1.js → 3.D_laBXeK.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.C0J24z3I.js → 30.DcMhZyQ0.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.MdxFI8v6.js → 31.CSwhNsNi.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.DCAOVzGE.js → 4.Cq28vPI2.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.DzUQ-cTc.js → 5.BTT9B3oI.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.CptBYTVj.js → 6.iCmmzXZp.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.DfeeQ0Rg.js → 7.qY5v7qqJ.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.CIcvctW7.js → 8.C6p4RvgK.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.QKrvq4RA.js → 9.f548N7zh.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +305 -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__/functions-context.test.ts +87 -0
  57. package/src/__tests__/functions-d1-proxy.test.ts +70 -0
  58. package/src/__tests__/functions-route.test.ts +15 -0
  59. package/src/__tests__/room-access-policy.test.ts +26 -0
  60. package/src/__tests__/room-handler-context.test.ts +2 -0
  61. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  62. package/src/durable-objects/room-runtime-base.ts +94 -11
  63. package/src/durable-objects/rooms-do.ts +4 -1
  64. package/src/lib/auth-d1-service.ts +37 -7
  65. package/src/lib/auth-token.ts +66 -0
  66. package/src/lib/email-provider.ts +115 -5
  67. package/src/lib/functions.ts +91 -1
  68. package/src/lib/internal-transport.ts +15 -1
  69. package/src/lib/jwt.ts +2 -0
  70. package/src/routes/admin.ts +8 -1
  71. package/src/routes/auth.ts +111 -19
  72. package/src/routes/room.ts +36 -2
  73. package/src/routes/storage.ts +446 -62
  74. package/src/types.ts +10 -0
  75. package/admin-build/_app/immutable/chunks/CKVjMXZi.js +0 -1
  76. package/admin-build/_app/immutable/chunks/Djnkhy-S.js +0 -1
  77. package/admin-build/_app/immutable/entry/start.JE7dcbK1.js +0 -1
  78. package/admin-build/_app/immutable/nodes/21.DOjJlQKc.js +0 -1
@@ -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(
@@ -1452,7 +1464,7 @@ authRoute.openapi(signinMagicLink, async (c) => {
1452
1464
  await sendMailWithHook(
1453
1465
  c.env, c.executionCtx, provider, 'magicLink', body.email,
1454
1466
  resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
1455
- ).catch(() => {});
1467
+ );
1456
1468
  } else {
1457
1469
  if (!isReleaseMode(c.env)) {
1458
1470
  debugToken = token;
@@ -1627,10 +1639,12 @@ authRoute.openapi(signinPhone, async (c) => {
1627
1639
  const code = generateOTP();
1628
1640
  if (exposeTestSecrets) devCode = code;
1629
1641
 
1642
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
1643
+
1630
1644
  // Store OTP in KV with 5 min TTL
1631
1645
  await c.env.KV.put(
1632
1646
  `phone-otp:${phone}`,
1633
- JSON.stringify({ code, userId, attempts: 0 }),
1647
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
1634
1648
  { expirationTtl: 300 },
1635
1649
  );
1636
1650
 
@@ -1679,10 +1693,12 @@ authRoute.openapi(signinPhone, async (c) => {
1679
1693
  const code = generateOTP();
1680
1694
  if (exposeTestSecrets) devCode = code;
1681
1695
 
1696
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
1697
+
1682
1698
  // Store OTP in KV
1683
1699
  await c.env.KV.put(
1684
1700
  `phone-otp:${phone}`,
1685
- JSON.stringify({ code, userId, attempts: 0 }),
1701
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
1686
1702
  { expirationTtl: 300 },
1687
1703
  );
1688
1704
 
@@ -1756,7 +1772,7 @@ authRoute.openapi(verifyPhone, async (c) => {
1756
1772
 
1757
1773
  // Look up phone → userId via KV OTP data
1758
1774
  const otpData = await c.env.KV.get(`phone-otp:${phone}`, 'json') as {
1759
- code: string; userId: string; attempts: number;
1775
+ codeHash?: string; code?: string; userId: string; attempts: number;
1760
1776
  } | null;
1761
1777
 
1762
1778
  if (!otpData) {
@@ -1769,8 +1785,11 @@ authRoute.openapi(verifyPhone, async (c) => {
1769
1785
  throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
1770
1786
  }
1771
1787
 
1772
- // Verify code (timing-safe comparison)
1773
- if (!timingSafeEqual(otpData.code, body.code)) {
1788
+ const submittedCode = body.code.trim();
1789
+ const otpMatches = otpData.codeHash
1790
+ ? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
1791
+ : timingSafeEqual(otpData.code ?? '', submittedCode);
1792
+ if (!otpMatches) {
1774
1793
  await c.env.KV.put(
1775
1794
  `phone-otp:${phone}`,
1776
1795
  JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
@@ -1895,10 +1914,12 @@ authRoute.openapi(linkPhone, async (c) => {
1895
1914
  const code = generateOTP();
1896
1915
  const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
1897
1916
 
1917
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
1918
+
1898
1919
  // Store link OTP in KV (separate key pattern)
1899
1920
  await c.env.KV.put(
1900
1921
  `phone-link-otp:${phone}`,
1901
- JSON.stringify({ code, userId, attempts: 0 }),
1922
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
1902
1923
  { expirationTtl: 300 },
1903
1924
  );
1904
1925
 
@@ -1964,7 +1985,7 @@ authRoute.openapi(verifyLinkPhone, async (c) => {
1964
1985
 
1965
1986
  // Verify OTP from KV
1966
1987
  const otpData = await c.env.KV.get(`phone-link-otp:${phone}`, 'json') as {
1967
- code: string; userId: string; attempts: number;
1988
+ codeHash?: string; code?: string; userId: string; attempts: number;
1968
1989
  } | null;
1969
1990
 
1970
1991
  if (!otpData) {
@@ -1980,7 +2001,11 @@ authRoute.openapi(verifyLinkPhone, async (c) => {
1980
2001
  throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
1981
2002
  }
1982
2003
 
1983
- if (!timingSafeEqual(otpData.code, body.code)) {
2004
+ const submittedCode = body.code.trim();
2005
+ const otpMatches = otpData.codeHash
2006
+ ? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
2007
+ : timingSafeEqual(otpData.code ?? '', submittedCode);
2008
+ if (!otpMatches) {
1984
2009
  await c.env.KV.put(
1985
2010
  `phone-link-otp:${phone}`,
1986
2011
  JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
@@ -2083,11 +2108,12 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2083
2108
 
2084
2109
  const code = generateOTP();
2085
2110
  if (exposeTestSecrets) devCode = code;
2111
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
2086
2112
 
2087
2113
  // Store OTP in KV with 5 min TTL
2088
2114
  await c.env.KV.put(
2089
2115
  `email-otp:${email}`,
2090
- JSON.stringify({ code, userId, attempts: 0 }),
2116
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
2091
2117
  { expirationTtl: 300 },
2092
2118
  );
2093
2119
 
@@ -2141,11 +2167,12 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2141
2167
 
2142
2168
  const code = generateOTP();
2143
2169
  if (exposeTestSecrets) devCode = code;
2170
+ const codeHash = await hashOtpSecret(code, getUserSecret(c.env));
2144
2171
 
2145
2172
  // Store OTP in KV
2146
2173
  await c.env.KV.put(
2147
2174
  `email-otp:${email}`,
2148
- JSON.stringify({ code, userId, attempts: 0 }),
2175
+ JSON.stringify({ codeHash, userId, attempts: 0 }),
2149
2176
  { expirationTtl: 300 },
2150
2177
  );
2151
2178
 
@@ -2218,7 +2245,7 @@ authRoute.openapi(verifyEmailOtp, async (c) => {
2218
2245
 
2219
2246
  // Look up OTP data from KV
2220
2247
  const otpData = await c.env.KV.get(`email-otp:${email}`, 'json') as {
2221
- code: string; userId: string; attempts: number;
2248
+ codeHash?: string; code?: string; userId: string; attempts: number;
2222
2249
  } | null;
2223
2250
 
2224
2251
  if (!otpData) {
@@ -2230,7 +2257,11 @@ authRoute.openapi(verifyEmailOtp, async (c) => {
2230
2257
  throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
2231
2258
  }
2232
2259
 
2233
- if (!timingSafeEqual(otpData.code, body.code)) {
2260
+ const submittedCode = body.code.trim();
2261
+ const otpMatches = otpData.codeHash
2262
+ ? await verifyOtpSecret(submittedCode, otpData.codeHash, getUserSecret(c.env))
2263
+ : timingSafeEqual(otpData.code ?? '', submittedCode);
2264
+ if (!otpMatches) {
2234
2265
  await c.env.KV.put(
2235
2266
  `email-otp:${email}`,
2236
2267
  JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
@@ -2577,6 +2608,67 @@ authRoute.openapi(mfaRecovery, async (c) => {
2577
2608
  });
2578
2609
  });
2579
2610
 
2611
+ // POST /mfa/recovery-codes — regenerate recovery codes (authenticated)
2612
+ const mfaRecoveryCodesRegenerate = createRoute({
2613
+ operationId: 'authMfaRecoveryCodesRegenerate',
2614
+ method: 'post',
2615
+ path: '/mfa/recovery-codes',
2616
+ tags: ['client'],
2617
+ summary: 'Regenerate MFA recovery codes',
2618
+ request: {
2619
+ body: { content: { 'application/json': { schema: z.object({
2620
+ password: z.string().optional(),
2621
+ code: z.string().optional(),
2622
+ }).passthrough() } }, required: false },
2623
+ },
2624
+ responses: {
2625
+ 200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
2626
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
2627
+ 401: { description: 'Authentication required or verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
2628
+ },
2629
+ });
2630
+
2631
+ authRoute.openapi(mfaRecoveryCodesRegenerate, async (c) => {
2632
+ const userId = requireAuth(c.get('auth'));
2633
+ const bodyText = await c.req.text();
2634
+ let body: { password?: string; code?: string } = {};
2635
+ try { body = bodyText ? JSON.parse(bodyText) : {}; } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
2636
+ await ensureAuthActionAllowed(c, 'mfaRecoveryCodesRegenerate', {
2637
+ userId,
2638
+ passwordProvided: !!body.password,
2639
+ codeProvided: !!body.code,
2640
+ });
2641
+ const db = getAuthDb(c);
2642
+
2643
+ const user = await authService.getUserById(db, userId);
2644
+ if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
2645
+ const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
2646
+ if (!factor || !factor.verified) {
2647
+ throw new EdgeBaseError(400, 'No verified TOTP factor found.', undefined, 'invalid-input');
2648
+ }
2649
+
2650
+ if (body.password) {
2651
+ if (!user.passwordHash) throw new EdgeBaseError(400, 'This account has no password set.', undefined, 'invalid-input');
2652
+ const valid = await verifyPassword(body.password, user.passwordHash as string);
2653
+ if (!valid) throw new EdgeBaseError(401, 'Invalid password.', undefined, 'invalid-password');
2654
+ } else if (body.code) {
2655
+ const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
2656
+ const valid = await verifyTOTP(secret, body.code);
2657
+ if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
2658
+ } else {
2659
+ throw new EdgeBaseError(400, 'Either password or TOTP code is required to regenerate recovery codes.', undefined, 'invalid-input');
2660
+ }
2661
+
2662
+ const recoveryCodes = generateRecoveryCodes(8);
2663
+ const hashedCodes: { id: string; codeHash: string }[] = [];
2664
+ for (const code of recoveryCodes) {
2665
+ hashedCodes.push({ id: generateId(), codeHash: await hashRecoveryCode(code) });
2666
+ }
2667
+ await authService.replaceRecoveryCodes(db, userId, hashedCodes);
2668
+
2669
+ return c.json({ recoveryCodes });
2670
+ });
2671
+
2580
2672
  // DELETE /mfa/totp — disable TOTP (authenticated)
2581
2673
  const mfaTotpDelete = createRoute({
2582
2674
  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 {