@edge-base/server 0.2.9 → 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/{BfhqI0W8.js → BSIzoJ5I.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → BeYswbvA.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BkrC_6Ss.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{DBniE7GN.js → BunyH6GE.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{C5nMidnK.js → C-zXt-cq.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → C1Mycuvd.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{0M5txo3l.js → Cek51lkE.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/{_xa30BOD.js → D0mNYcxN.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/D3hdqsfp.js +1 -0
  10. package/admin-build/_app/immutable/chunks/{39YMyjjq.js → DF40xjpj.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → DOOhHO-v.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/DdjFeYwG.js +1 -0
  13. package/admin-build/_app/immutable/chunks/{DllVADtt.js → Dvlxm2Iq.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Xm1dHMTE.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → aPulNooa.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DBkor_jM.js → dZsKGnWZ.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.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.CRSQPtos.js → 0.Oo7ZpbR-.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.zgMnKHns.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.u5mkRiUe.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.9ynAgaxV.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.M_EqzJ6s.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.D_NQ9q60.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.-vENIJ2w.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.DoMCZhGv.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.6BmnzScq.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.q6QQ9N_J.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.DUhJ6dKj.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.CvkvPlGP.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.JQer3KPM.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.DUw7X6z_.js → 22.BsQ6xeKr.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.CNW3mLz5.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.CReqtEOy.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.BuxVhqlb.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.C-LzPf62.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.Dl5RL_tB.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.CWfSQgjH.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.wUSfh2eQ.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.D_laBXeK.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.DcMhZyQ0.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.CSwhNsNi.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.Cq28vPI2.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.BTT9B3oI.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.iCmmzXZp.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.qY5v7qqJ.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.C6p4RvgK.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.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 +16 -5
  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/B0zJ5_Wk.js +0 -1
  76. package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
  77. package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
  78. package/admin-build/_app/immutable/nodes/21.CzGYxjpu.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import {
3
+ CloudflareEmailProvider,
3
4
  SESProvider,
4
5
  SendGridProvider,
5
6
  createEmailProvider,
@@ -78,6 +79,87 @@ describe('SendGridProvider', () => {
78
79
  });
79
80
  });
80
81
 
82
+ describe('CloudflareEmailProvider', () => {
83
+ it('uses the Workers send_email binding when available', async () => {
84
+ const send = vi.fn().mockResolvedValue({ messageId: 'cf-binding-1' });
85
+
86
+ const provider = createEmailProvider(
87
+ { provider: 'cloudflare', from: 'noreply@example.com' },
88
+ { EMAIL: { send } },
89
+ );
90
+ expect(provider).not.toBeNull();
91
+
92
+ const result = await provider!.send({
93
+ to: 'user@example.com',
94
+ subject: 'Hello',
95
+ html: '<p>hello</p>',
96
+ });
97
+
98
+ expect(result).toEqual({ success: true, messageId: 'cf-binding-1' });
99
+ expect(send).toHaveBeenCalledWith({
100
+ to: 'user@example.com',
101
+ from: 'noreply@example.com',
102
+ subject: 'Hello',
103
+ html: '<p>hello</p>',
104
+ });
105
+ });
106
+
107
+ it('sends through the Cloudflare Email Service REST API when no binding exists', async () => {
108
+ const fetchMock = vi.fn().mockResolvedValue(
109
+ new Response(JSON.stringify({
110
+ success: true,
111
+ errors: [],
112
+ messages: [],
113
+ result: { delivered: ['user@example.com'], permanent_bounces: [], queued: [] },
114
+ }), {
115
+ status: 200,
116
+ headers: { 'content-type': 'application/json' },
117
+ }),
118
+ );
119
+ vi.stubGlobal('fetch', fetchMock);
120
+
121
+ const provider = new CloudflareEmailProvider({
122
+ from: 'noreply@example.com',
123
+ accountId: 'acct-1',
124
+ apiKey: 'cf-token',
125
+ });
126
+ const result = await provider.send({
127
+ to: 'user@example.com',
128
+ subject: 'Hello',
129
+ html: '<p>hello</p>',
130
+ });
131
+
132
+ expect(result).toEqual({ success: true });
133
+ expect(fetchMock).toHaveBeenCalledWith(
134
+ 'https://api.cloudflare.com/client/v4/accounts/acct-1/email/sending/send',
135
+ expect.objectContaining({
136
+ method: 'POST',
137
+ headers: expect.objectContaining({
138
+ Authorization: 'Bearer cf-token',
139
+ 'Content-Type': 'application/json',
140
+ }),
141
+ }),
142
+ );
143
+ });
144
+
145
+ it('fails without REST credentials when no Workers binding exists', async () => {
146
+ const fetchMock = vi.fn();
147
+ vi.stubGlobal('fetch', fetchMock);
148
+
149
+ const provider = createEmailProvider({ provider: 'cloudflare', from: 'noreply@example.com' });
150
+ expect(provider).not.toBeNull();
151
+
152
+ const result = await provider!.send({
153
+ to: 'user@example.com',
154
+ subject: 'Hello',
155
+ html: '<p>hello</p>',
156
+ });
157
+
158
+ expect(result).toEqual({ success: false });
159
+ expect(fetchMock).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
81
163
  describe('SESProvider', () => {
82
164
  it('signs the outbound request with AWS SigV4 headers', async () => {
83
165
  const fetchMock = vi.fn().mockResolvedValue(
@@ -10,6 +10,28 @@ describe('buildFunctionContext admin.db', () => {
10
10
  vi.unstubAllGlobals();
11
11
  });
12
12
 
13
+ it('exposes worker environment values to app functions', async () => {
14
+ const env = {
15
+ NOTION_IMPORT_SECRET: 'test-secret',
16
+ CUSTOM_BINDING: { id: 'binding' },
17
+ };
18
+
19
+ const ctx = buildFunctionContext({
20
+ request: new Request('http://localhost/api/functions/feed-summary'),
21
+ auth: null,
22
+ databaseNamespace: {} as DurableObjectNamespace,
23
+ authNamespace: {} as DurableObjectNamespace,
24
+ d1Database: {} as D1Database,
25
+ config: {
26
+ databases: {},
27
+ },
28
+ env: env as never,
29
+ });
30
+
31
+ expect(ctx.env?.NOTION_IMPORT_SECRET).toBe('test-secret');
32
+ expect(ctx.env?.CUSTOM_BINDING).toBe(env.CUSTOM_BINDING);
33
+ });
34
+
13
35
  it('routes table proxy calls through the worker when workerUrl is available', async () => {
14
36
  const fetchMock = vi.fn().mockResolvedValue(
15
37
  new Response(JSON.stringify({ items: [{ id: 'p1' }] }), {
@@ -197,6 +219,71 @@ describe('buildFunctionContext admin.db', () => {
197
219
  );
198
220
  });
199
221
 
222
+ it('exposes the configured email provider to app functions', async () => {
223
+ const fetchMock = vi.fn().mockResolvedValue(
224
+ new Response(JSON.stringify({ messageId: 'msg_1' }), {
225
+ status: 200,
226
+ headers: { 'Content-Type': 'application/json' },
227
+ }),
228
+ );
229
+ vi.stubGlobal('fetch', fetchMock);
230
+
231
+ const ctx = buildFunctionContext({
232
+ request: new Request('http://localhost/api/functions/send-invite'),
233
+ auth: null,
234
+ databaseNamespace: {} as DurableObjectNamespace,
235
+ authNamespace: {} as DurableObjectNamespace,
236
+ d1Database: {} as D1Database,
237
+ config: {
238
+ email: {
239
+ provider: 'cloudflare',
240
+ from: 'noreply@example.com',
241
+ },
242
+ },
243
+ env: {
244
+ EDGEBASE_EMAIL_API_URL: 'http://mail.local',
245
+ } as never,
246
+ });
247
+
248
+ expect(ctx.email).toBeDefined();
249
+ await expect(
250
+ ctx.email?.send({
251
+ to: 'member@example.com',
252
+ subject: 'Workspace invite',
253
+ html: '<p>Join the workspace</p>',
254
+ }),
255
+ ).resolves.toEqual({ success: true, messageId: 'msg_1' });
256
+
257
+ expect(fetchMock).toHaveBeenCalledWith(
258
+ 'http://mail.local/send',
259
+ expect.objectContaining({
260
+ method: 'POST',
261
+ headers: expect.objectContaining({
262
+ 'Content-Type': 'application/json',
263
+ }),
264
+ body: JSON.stringify({
265
+ from: 'noreply@example.com',
266
+ to: 'member@example.com',
267
+ subject: 'Workspace invite',
268
+ html: '<p>Join the workspace</p>',
269
+ }),
270
+ }),
271
+ );
272
+ });
273
+
274
+ it('omits the email sender when no provider is configured', () => {
275
+ const ctx = buildFunctionContext({
276
+ request: new Request('http://localhost/api/functions/no-email'),
277
+ auth: null,
278
+ databaseNamespace: {} as DurableObjectNamespace,
279
+ authNamespace: {} as DurableObjectNamespace,
280
+ d1Database: {} as D1Database,
281
+ config: {},
282
+ });
283
+
284
+ expect(ctx.email).toBeUndefined();
285
+ });
286
+
200
287
  it('rejects instance ids for single-instance admin.sqlProviderAware calls before touching direct backends', async () => {
201
288
  const fetchMock = vi.fn();
202
289
  vi.stubGlobal('fetch', fetchMock);
@@ -227,6 +227,76 @@ describe('buildFunctionContext admin.db D1 routing', () => {
227
227
  expect(databaseFetch).not.toHaveBeenCalled();
228
228
  });
229
229
 
230
+ it('falls back to the database durable object for implicit D1 namespaces when the D1 binding is absent', async () => {
231
+ const handleD1Request = vi.fn().mockRejectedValue(new Error('D1 handler should not be used'));
232
+ vi.doMock('../lib/d1-handler.js', () => ({
233
+ handleD1Request,
234
+ }));
235
+
236
+ const workerFetch = vi.fn().mockRejectedValue(new Error('worker fetch should not be used'));
237
+ vi.stubGlobal('fetch', workerFetch);
238
+
239
+ const databaseFetch = vi.fn().mockResolvedValue(
240
+ new Response(JSON.stringify({ items: [{ id: 'sig-do', title: 'Read via DO' }] }), {
241
+ status: 200,
242
+ headers: { 'Content-Type': 'application/json' },
243
+ }),
244
+ );
245
+ const databaseNamespace = {
246
+ idFromName: vi.fn(() => 'shared-id'),
247
+ get: vi.fn(() => ({ fetch: databaseFetch })),
248
+ } as unknown as DurableObjectNamespace;
249
+
250
+ const { buildFunctionContext } = await import('../lib/functions.js');
251
+
252
+ const ctx = buildFunctionContext({
253
+ request: new Request('http://localhost/api/functions/save-room-signal'),
254
+ auth: null,
255
+ databaseNamespace,
256
+ authNamespace: {
257
+ idFromName: vi.fn(() => 'auth-id'),
258
+ get: vi.fn(() => ({ fetch: vi.fn() })),
259
+ } as unknown as DurableObjectNamespace,
260
+ d1Database: {} as D1Database,
261
+ env: {
262
+ DATABASE: databaseNamespace,
263
+ AUTH: {} as DurableObjectNamespace,
264
+ AUTH_DB: {} as D1Database,
265
+ } as never,
266
+ executionCtx: { waitUntil: vi.fn() } as unknown as ExecutionContext,
267
+ config: {
268
+ databases: {
269
+ shared: {
270
+ tables: {
271
+ signals: {
272
+ schema: {
273
+ title: { type: 'string', required: true },
274
+ },
275
+ },
276
+ },
277
+ },
278
+ },
279
+ },
280
+ });
281
+
282
+ const result = await ctx.admin.db('shared').table('signals').getList();
283
+
284
+ expect(result.items).toEqual([{ id: 'sig-do', title: 'Read via DO' }]);
285
+ expect(handleD1Request).not.toHaveBeenCalled();
286
+ expect(workerFetch).not.toHaveBeenCalled();
287
+ expect(databaseNamespace.idFromName).toHaveBeenCalledWith('shared');
288
+ expect(databaseFetch).toHaveBeenCalledWith(
289
+ 'http://do/tables/signals',
290
+ expect.objectContaining({
291
+ method: 'GET',
292
+ headers: expect.objectContaining({
293
+ 'X-DO-Name': 'shared',
294
+ 'X-EdgeBase-Internal': 'true',
295
+ }),
296
+ }),
297
+ );
298
+ });
299
+
230
300
  it('rejects instance ids for single-instance namespaces before touching D1 handlers', async () => {
231
301
  const handleD1Request = vi.fn();
232
302
  vi.doMock('../lib/d1-handler.js', () => ({
@@ -8,6 +8,7 @@ import {
8
8
  rebuildCompiledRoutes,
9
9
  registerFunction,
10
10
  registerMiddleware,
11
+ wrapMethodExport,
11
12
  } from '../lib/functions.js';
12
13
  import { functionsRoute } from '../routes/functions.js';
13
14
  import type { Env } from '../types.js';
@@ -104,6 +105,20 @@ afterEach(() => {
104
105
  setConfig({});
105
106
  });
106
107
 
108
+ describe('function registry wrapping', () => {
109
+ it('preserves full default-export function definitions with non-HTTP triggers', () => {
110
+ const scheduleFunction: FunctionDefinition = {
111
+ trigger: { type: 'schedule', cron: '*/15 * * * *' },
112
+ handler: async () => ({ ok: true }),
113
+ };
114
+
115
+ const wrapped = wrapMethodExport(scheduleFunction, '*');
116
+
117
+ expect(wrapped).toBe(scheduleFunction);
118
+ expect(wrapped.trigger).toEqual({ type: 'schedule', cron: '*/15 * * * *' });
119
+ });
120
+ });
121
+
107
122
  describe('functionsRoute FunctionError compatibility', () => {
108
123
  it('returns structured JSON when directory middleware throws a foreign FunctionError', async () => {
109
124
  registerMiddleware('', async () => {
@@ -98,4 +98,30 @@ describe('Room route access policy', () => {
98
98
  expect(response.status).toBe(200);
99
99
  await expect(response.json()).resolves.toEqual({ visibility: 'public' });
100
100
  });
101
+
102
+ it('passes admin context into metadata access rules', async () => {
103
+ setConfig(defineConfig({
104
+ release: true,
105
+ rooms: {
106
+ game: {
107
+ access: {
108
+ metadata: (_auth, roomId, ctx) =>
109
+ roomId === 'room-1' && typeof ctx.admin.db === 'function',
110
+ },
111
+ },
112
+ },
113
+ }));
114
+
115
+ const app = createApp({
116
+ id: 'user-1',
117
+ role: 'user',
118
+ isAnonymous: false,
119
+ });
120
+ const response = await app.request('/api/room/metadata?namespace=game&id=room-1', {
121
+ method: 'GET',
122
+ }, createRoomEnv());
123
+
124
+ expect(response.status).toBe(200);
125
+ await expect(response.json()).resolves.toEqual({ visibility: 'public' });
126
+ });
101
127
  });
@@ -37,6 +37,7 @@ describe('RoomsDO handler context', () => {
37
37
  room.config = {
38
38
  databases: {
39
39
  shared: {
40
+ provider: 'do',
40
41
  tables: {
41
42
  signals: {
42
43
  schema: {
@@ -93,6 +94,7 @@ describe('RoomsDO handler context', () => {
93
94
  room.config = {
94
95
  databases: {
95
96
  shared: {
97
+ provider: 'do',
96
98
  tables: {
97
99
  signals: {
98
100
  schema: {
@@ -38,7 +38,7 @@ describe('smoke skip report', () => {
38
38
  {
39
39
  "skippedRouteCount": 0,
40
40
  "summaryByReason": {},
41
- "totalRoutes": 190,
41
+ "totalRoutes": 191,
42
42
  }
43
43
  `);
44
44
  });
@@ -962,7 +962,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
962
962
  }
963
963
  if (joinAccess && this.roomId) {
964
964
  try {
965
- const allowed = await Promise.resolve(joinAccess(this.buildAuthFromMeta(meta), this.roomId));
965
+ const accessCtx = await this.buildHandlerContext();
966
+ const allowed = await Promise.resolve(joinAccess(this.buildAuthFromMeta(meta), this.roomId, accessCtx));
966
967
  if (!allowed) {
967
968
  this.safeSend(ws, {
968
969
  type: 'error',
@@ -972,11 +973,19 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
972
973
  ws.close(4003, 'Join denied');
973
974
  return;
974
975
  }
975
- } catch {
976
+ } catch (error) {
977
+ const detail = error instanceof Error ? error.message : String(error);
978
+ console.error('[Room] join access rule failed', {
979
+ room: this.namespace && this.roomId ? `${this.namespace}::${this.roomId}` : null,
980
+ connectionId: meta.connectionId,
981
+ userId: meta.userId ?? null,
982
+ message: detail,
983
+ stack: error instanceof Error ? error.stack : undefined,
984
+ });
976
985
  this.safeSend(ws, {
977
986
  type: 'error',
978
987
  code: 'JOIN_DENIED',
979
- message: 'Denied by room join access rule',
988
+ message: this.config.release ? 'Denied by room join access rule' : `Denied by room join access rule: ${detail}`,
980
989
  });
981
990
  ws.close(4003, 'Join denied');
982
991
  return;
@@ -1111,11 +1120,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1111
1120
  }
1112
1121
  if (actionAccess && this.roomId) {
1113
1122
  try {
1123
+ const accessCtx = await this.buildHandlerContext();
1114
1124
  const allowed = await Promise.resolve(actionAccess(
1115
1125
  this.buildAuthFromMeta(meta),
1116
1126
  this.roomId,
1117
1127
  actionType,
1118
1128
  payload,
1129
+ accessCtx,
1119
1130
  ));
1120
1131
  if (!allowed) {
1121
1132
  this.safeSend(ws, {
@@ -1400,8 +1411,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1400
1411
  env: this.env as never,
1401
1412
  executionCtx: this.ctx as never,
1402
1413
  serviceKey: resolveRootServiceKey(this.config, this.env as never),
1403
- // Room handlers run inside a DO and should always talk to DB DOs directly.
1404
- preferDirectDoDb: true,
1414
+ // Keep room handler DB access provider-aware. Static D1/Postgres apps must
1415
+ // read the same store as function routes, while DO-backed DBs still use DOs.
1405
1416
  });
1406
1417
  return {
1407
1418
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -801,12 +801,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
801
801
  }
802
802
 
803
803
  try {
804
+ const accessCtx = await this.buildHandlerContext();
804
805
  return await Promise.resolve(
805
806
  this.namespaceConfig.access.signal(
806
807
  this.buildAuthFromMeta(meta),
807
808
  this.roomId,
808
809
  event,
809
810
  payload,
811
+ accessCtx,
810
812
  ),
811
813
  );
812
814
  } catch {
@@ -948,8 +950,9 @@ export class RoomsDO extends RoomRuntimeBaseDO {
948
950
  }
949
951
 
950
952
  try {
953
+ const accessCtx = await this.buildHandlerContext();
951
954
  return await Promise.resolve(
952
- adminAccess(this.buildAuthFromMeta(meta), this.roomId, operation, payload),
955
+ adminAccess(this.buildAuthFromMeta(meta), this.roomId, operation, payload, accessCtx),
953
956
  );
954
957
  } catch {
955
958
  return false;
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { AuthDb } from './auth-db-adapter.js';
17
+ import { authSecretLookupKeys, hashAuthSecret } from './auth-token.js';
17
18
 
18
19
  // ─── Types ───
19
20
 
@@ -660,11 +661,12 @@ export async function createEmailToken(
660
661
  input: CreateEmailTokenInput,
661
662
  ): Promise<void> {
662
663
  const now = new Date().toISOString();
664
+ const tokenHash = await hashAuthSecret(input.token);
663
665
 
664
666
  await db.run(
665
667
  `INSERT INTO _email_tokens (token, userId, type, expiresAt, createdAt)
666
668
  VALUES (?, ?, ?, ?, ?)`,
667
- [input.token, input.userId, input.type, input.expiresAt, now],
669
+ [tokenHash, input.userId, input.type, input.expiresAt, now],
668
670
  );
669
671
  }
670
672
 
@@ -676,9 +678,10 @@ export async function getEmailToken(
676
678
  db: AuthDb,
677
679
  token: string,
678
680
  ): Promise<Record<string, unknown> | null> {
681
+ const lookupKeys = await authSecretLookupKeys(token);
679
682
  const row = await db.first(
680
- `SELECT * FROM _email_tokens WHERE token = ?`,
681
- [token],
683
+ `SELECT * FROM _email_tokens WHERE token IN (${lookupKeys.map(() => '?').join(', ')})`,
684
+ lookupKeys,
682
685
  );
683
686
 
684
687
  if (!row) return null;
@@ -686,7 +689,10 @@ export async function getEmailToken(
686
689
  // Check expiration
687
690
  if (new Date(row.expiresAt as string) < new Date()) {
688
691
  // Clean up expired token
689
- await db.run(`DELETE FROM _email_tokens WHERE token = ?`, [token]);
692
+ await db.run(
693
+ `DELETE FROM _email_tokens WHERE token IN (${lookupKeys.map(() => '?').join(', ')})`,
694
+ lookupKeys,
695
+ );
690
696
  return null;
691
697
  }
692
698
 
@@ -702,9 +708,10 @@ export async function getEmailTokenByType(
702
708
  token: string,
703
709
  type: string,
704
710
  ): Promise<Record<string, unknown> | null> {
711
+ const lookupKeys = await authSecretLookupKeys(token);
705
712
  return await db.first(
706
- `SELECT * FROM _email_tokens WHERE token = ? AND type = ?`,
707
- [token, type],
713
+ `SELECT * FROM _email_tokens WHERE token IN (${lookupKeys.map(() => '?').join(', ')}) AND type = ?`,
714
+ [...lookupKeys, type],
708
715
  );
709
716
  }
710
717
 
@@ -715,7 +722,11 @@ export async function deleteEmailToken(
715
722
  db: AuthDb,
716
723
  token: string,
717
724
  ): Promise<void> {
718
- await db.run(`DELETE FROM _email_tokens WHERE token = ?`, [token]);
725
+ const lookupKeys = await authSecretLookupKeys(token);
726
+ await db.run(
727
+ `DELETE FROM _email_tokens WHERE token IN (${lookupKeys.map(() => '?').join(', ')})`,
728
+ lookupKeys,
729
+ );
719
730
  }
720
731
 
721
732
  /**
@@ -923,6 +934,25 @@ export async function createRecoveryCodes(
923
934
  );
924
935
  }
925
936
 
937
+ /**
938
+ * Replace all recovery codes for a user with a freshly generated batch.
939
+ */
940
+ export async function replaceRecoveryCodes(
941
+ db: AuthDb,
942
+ userId: string,
943
+ codes: Array<{ id: string; codeHash: string }>,
944
+ ): Promise<void> {
945
+ const now = new Date().toISOString();
946
+ await db.batch([
947
+ { sql: `DELETE FROM _mfa_recovery_codes WHERE userId = ?`, params: [userId] },
948
+ ...codes.map((code) => ({
949
+ sql: `INSERT INTO _mfa_recovery_codes (id, userId, codeHash, used, createdAt)
950
+ VALUES (?, ?, ?, 0, ?)`,
951
+ params: [code.id, userId, code.codeHash, now],
952
+ })),
953
+ ]);
954
+ }
955
+
926
956
  /**
927
957
  * List unused recovery codes for a user.
928
958
  */
@@ -0,0 +1,66 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ function toHex(value: ArrayBuffer): string {
4
+ return Array.from(new Uint8Array(value))
5
+ .map((byte) => byte.toString(16).padStart(2, '0'))
6
+ .join('');
7
+ }
8
+
9
+ function timingSafeStringEqual(a: string, b: string): boolean {
10
+ if (a.length !== b.length) return false;
11
+ let result = 0;
12
+ for (let i = 0; i < a.length; i += 1) {
13
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
14
+ }
15
+ return result === 0;
16
+ }
17
+
18
+ async function hmacSha256Hex(key: string, value: string): Promise<string> {
19
+ const cryptoKey = await crypto.subtle.importKey(
20
+ 'raw',
21
+ encoder.encode(key),
22
+ { name: 'HMAC', hash: 'SHA-256' },
23
+ false,
24
+ ['sign'],
25
+ );
26
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(value));
27
+ return toHex(signature);
28
+ }
29
+
30
+ export async function hashAuthSecret(secret: string): Promise<string> {
31
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(secret));
32
+ return `sha256:${toHex(digest)}`;
33
+ }
34
+
35
+ export async function authSecretLookupKeys(secret: string): Promise<string[]> {
36
+ const hashed = await hashAuthSecret(secret);
37
+ return secret.startsWith('sha256:') ? [secret] : [hashed, secret];
38
+ }
39
+
40
+ export async function verifyAuthSecret(secret: string, storedHash: string): Promise<boolean> {
41
+ const candidate = await hashAuthSecret(secret);
42
+ return timingSafeStringEqual(candidate, storedHash);
43
+ }
44
+
45
+ export async function hashOtpSecret(otp: string, serverSecret: string): Promise<string> {
46
+ const digest = await hmacSha256Hex(serverSecret, `edgebase:auth:otp:${otp}`);
47
+ return `hmac-sha256:${digest}`;
48
+ }
49
+
50
+ export async function verifyOtpSecret(
51
+ otp: string,
52
+ storedHash: string,
53
+ serverSecret: string,
54
+ ): Promise<boolean> {
55
+ if (storedHash.startsWith('hmac-sha256:')) {
56
+ const candidate = await hashOtpSecret(otp, serverSecret);
57
+ return timingSafeStringEqual(candidate, storedHash);
58
+ }
59
+
60
+ // Backward compatibility for short-lived OTPs stored before HMAC hashing.
61
+ if (storedHash.startsWith('sha256:')) {
62
+ return verifyAuthSecret(otp, storedHash);
63
+ }
64
+
65
+ return false;
66
+ }