@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.
- package/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BSIzoJ5I.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → BeYswbvA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BkrC_6Ss.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DBniE7GN.js → BunyH6GE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{C5nMidnK.js → C-zXt-cq.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → C1Mycuvd.js} +1 -1
- package/admin-build/_app/immutable/chunks/{0M5txo3l.js → Cek51lkE.js} +3 -3
- package/admin-build/_app/immutable/chunks/{_xa30BOD.js → D0mNYcxN.js} +1 -1
- package/admin-build/_app/immutable/chunks/D3hdqsfp.js +1 -0
- package/admin-build/_app/immutable/chunks/{39YMyjjq.js → DF40xjpj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → DOOhHO-v.js} +1 -1
- package/admin-build/_app/immutable/chunks/DdjFeYwG.js +1 -0
- package/admin-build/_app/immutable/chunks/{DllVADtt.js → Dvlxm2Iq.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Xm1dHMTE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → aPulNooa.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DBkor_jM.js → dZsKGnWZ.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.j1WHBlZX.js} +2 -2
- package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.Oo7ZpbR-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.zgMnKHns.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.u5mkRiUe.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.9ynAgaxV.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.M_EqzJ6s.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.D_NQ9q60.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.-vENIJ2w.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.DoMCZhGv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.6BmnzScq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.q6QQ9N_J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.DUhJ6dKj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.CvkvPlGP.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.cACSbRIl.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.BsQ6xeKr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.CNW3mLz5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.CReqtEOy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.BuxVhqlb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.C-LzPf62.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.Dl5RL_tB.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.CWfSQgjH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.wUSfh2eQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.D_laBXeK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.DcMhZyQ0.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.CSwhNsNi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.Cq28vPI2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.BTT9B3oI.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.iCmmzXZp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.qY5v7qqJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.C6p4RvgK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.f548N7zh.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +305 -3
- package/package.json +3 -3
- package/src/__tests__/auth-token.test.ts +142 -0
- package/src/__tests__/email-provider.test.ts +82 -0
- package/src/__tests__/functions-context.test.ts +87 -0
- package/src/__tests__/functions-d1-proxy.test.ts +70 -0
- package/src/__tests__/functions-route.test.ts +15 -0
- package/src/__tests__/room-access-policy.test.ts +26 -0
- package/src/__tests__/room-handler-context.test.ts +2 -0
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/room-runtime-base.ts +16 -5
- package/src/durable-objects/rooms-do.ts +4 -1
- package/src/lib/auth-d1-service.ts +37 -7
- package/src/lib/auth-token.ts +66 -0
- package/src/lib/email-provider.ts +115 -5
- package/src/lib/functions.ts +91 -1
- package/src/lib/internal-transport.ts +15 -1
- package/src/lib/jwt.ts +2 -0
- package/src/routes/admin.ts +8 -1
- package/src/routes/auth.ts +111 -19
- package/src/routes/room.ts +36 -2
- package/src/routes/storage.ts +446 -62
- package/src/types.ts +10 -0
- package/admin-build/_app/immutable/chunks/B0zJ5_Wk.js +0 -1
- package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
- package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
- 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: {
|
|
@@ -962,7 +962,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
962
962
|
}
|
|
963
963
|
if (joinAccess && this.roomId) {
|
|
964
964
|
try {
|
|
965
|
-
const
|
|
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
|
-
//
|
|
1404
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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(
|
|
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
|
|
707
|
-
[
|
|
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
|
-
|
|
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
|
+
}
|