@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.
- package/admin-build/_app/immutable/chunks/{zl2AUKMP.js → BSIzoJ5I.js} +1 -1
- package/admin-build/_app/immutable/chunks/{j4jxnAKj.js → BeYswbvA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DcVb45Ds.js → BkrC_6Ss.js} +1 -1
- package/admin-build/_app/immutable/chunks/{fPy6xmgG.js → BunyH6GE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B9efkx2V.js → C-zXt-cq.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DYtrHeVQ.js → C1Mycuvd.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Bt4AyT3o.js → Cek51lkE.js} +3 -3
- package/admin-build/_app/immutable/chunks/{D8aeTKry.js → D0mNYcxN.js} +1 -1
- package/admin-build/_app/immutable/chunks/D3hdqsfp.js +1 -0
- package/admin-build/_app/immutable/chunks/{DPgR4-0v.js → DF40xjpj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CTRjWhGs.js → DOOhHO-v.js} +1 -1
- package/admin-build/_app/immutable/chunks/DdjFeYwG.js +1 -0
- package/admin-build/_app/immutable/chunks/{CwyE59Yt.js → Dvlxm2Iq.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BMXWUTG-.js → Xm1dHMTE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DGAHkap7.js → aPulNooa.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CMYgGhZR.js → dZsKGnWZ.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.Cmz0WjMl.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.y6D_QyUb.js → 0.Oo7ZpbR-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.CndRxhbH.js → 1.zgMnKHns.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.CdA5FmXy.js → 10.u5mkRiUe.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.DG8SzMp_.js → 11.9ynAgaxV.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CvmQqpFa.js → 12.M_EqzJ6s.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.BbGNdswT.js → 13.D_NQ9q60.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.CZKsN7-O.js → 14.-vENIJ2w.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.A7-CYgkG.js → 15.DoMCZhGv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.hgJT9H-x.js → 16.6BmnzScq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.DkWZbcN2.js → 17.q6QQ9N_J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.sX3Fb5gh.js → 18.DUhJ6dKj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.VAZUW-1K.js → 19.CvkvPlGP.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.DkIKxacG.js → 20.cACSbRIl.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.BDaHvtaw.js → 22.BsQ6xeKr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.BVRzw_pD.js → 23.CNW3mLz5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.CVhSJyG0.js → 24.CReqtEOy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.Bme-9bZn.js → 25.BuxVhqlb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.Dsx7RIIs.js → 26.C-LzPf62.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.DMGQnzFM.js → 27.Dl5RL_tB.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.GGwFmEhZ.js → 28.CWfSQgjH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.Dnghr0nk.js → 29.wUSfh2eQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.Cg7zZJP1.js → 3.D_laBXeK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.C0J24z3I.js → 30.DcMhZyQ0.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.MdxFI8v6.js → 31.CSwhNsNi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.DCAOVzGE.js → 4.Cq28vPI2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.DzUQ-cTc.js → 5.BTT9B3oI.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.CptBYTVj.js → 6.iCmmzXZp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.DfeeQ0Rg.js → 7.qY5v7qqJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.CIcvctW7.js → 8.C6p4RvgK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.QKrvq4RA.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 +94 -11
- 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/CKVjMXZi.js +0 -1
- package/admin-build/_app/immutable/chunks/Djnkhy-S.js +0 -1
- package/admin-build/_app/immutable/entry/start.JE7dcbK1.js +0 -1
- package/admin-build/_app/immutable/nodes/21.DOjJlQKc.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: {
|
|
@@ -52,6 +52,33 @@ export interface RoomWSMeta {
|
|
|
52
52
|
lastSeenAt?: number;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function normalizeAnonymousRoomAuthPayload(value: unknown): SharedAuthContext | null {
|
|
56
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const typed = value as Record<string, unknown>;
|
|
61
|
+
if (typed.type !== 'anonymous' || typeof typed.subject !== 'string' || typed.subject.trim().length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const subject = typed.subject.trim();
|
|
66
|
+
return {
|
|
67
|
+
id: subject,
|
|
68
|
+
role: typeof typed.role === 'string' && typed.role.trim().length > 0 ? typed.role.trim() : undefined,
|
|
69
|
+
email: typeof typed.email === 'string' && typed.email.trim().length > 0 ? typed.email.trim() : undefined,
|
|
70
|
+
isAnonymous: typed.isAnonymous !== false,
|
|
71
|
+
custom:
|
|
72
|
+
typed.custom && typeof typed.custom === 'object' && !Array.isArray(typed.custom)
|
|
73
|
+
? (typed.custom as Record<string, unknown>)
|
|
74
|
+
: undefined,
|
|
75
|
+
meta:
|
|
76
|
+
typed.meta && typeof typed.meta === 'object' && !Array.isArray(typed.meta)
|
|
77
|
+
? (typed.meta as Record<string, unknown>)
|
|
78
|
+
: undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
interface PersistedRoomWSAttachment {
|
|
56
83
|
version: 1;
|
|
57
84
|
meta: RoomWSMeta;
|
|
@@ -489,7 +516,12 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
489
516
|
|
|
490
517
|
// Auth must be first
|
|
491
518
|
if (type === 'auth') {
|
|
492
|
-
await this.handleAuth(
|
|
519
|
+
await this.handleAuth(
|
|
520
|
+
ws,
|
|
521
|
+
meta,
|
|
522
|
+
typeof msg.token === 'string' ? msg.token : undefined,
|
|
523
|
+
msg.authPayload,
|
|
524
|
+
);
|
|
493
525
|
return;
|
|
494
526
|
}
|
|
495
527
|
|
|
@@ -765,15 +797,53 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
765
797
|
|
|
766
798
|
// ─── Auth Handler ───
|
|
767
799
|
|
|
768
|
-
private async handleAuth(
|
|
800
|
+
private async handleAuth(
|
|
801
|
+
ws: WebSocket,
|
|
802
|
+
meta: RoomWSMeta,
|
|
803
|
+
token?: string,
|
|
804
|
+
authPayload?: unknown,
|
|
805
|
+
): Promise<void> {
|
|
769
806
|
const isReAuth = meta.authenticated;
|
|
770
807
|
|
|
771
|
-
|
|
808
|
+
const anonymousAuth = !token ? normalizeAnonymousRoomAuthPayload(authPayload) : null;
|
|
809
|
+
if (!token && !anonymousAuth) {
|
|
772
810
|
this.safeSend(ws, { type: 'error', code: 'AUTH_FAILED', message: 'Token required' });
|
|
773
811
|
ws.close(4002, 'Authentication failed');
|
|
774
812
|
return;
|
|
775
813
|
}
|
|
776
814
|
|
|
815
|
+
if (anonymousAuth) {
|
|
816
|
+
meta.authenticated = true;
|
|
817
|
+
meta.authStateLost = false;
|
|
818
|
+
meta.lastSeenAt = Date.now();
|
|
819
|
+
meta.userId = anonymousAuth.id;
|
|
820
|
+
meta.role = anonymousAuth.role;
|
|
821
|
+
meta.auth = anonymousAuth;
|
|
822
|
+
this.setWSMeta(ws, meta);
|
|
823
|
+
|
|
824
|
+
if (this.pendingAuth.delete(meta.connectionId)) {
|
|
825
|
+
this.syncEphemeralTimersToStorage();
|
|
826
|
+
this._scheduleNextAlarm();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (!isReAuth && meta.userId) {
|
|
830
|
+
if (this.disconnectTimers.delete(meta.userId)) {
|
|
831
|
+
this.syncEphemeralTimersToStorage();
|
|
832
|
+
this._scheduleNextAlarm();
|
|
833
|
+
}
|
|
834
|
+
this.addPlayer(meta.connectionId, meta.userId);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
this.safeSend(ws, {
|
|
838
|
+
type: isReAuth ? 'auth_refreshed' : 'auth_success',
|
|
839
|
+
userId: anonymousAuth.id,
|
|
840
|
+
connectionId: meta.connectionId,
|
|
841
|
+
});
|
|
842
|
+
this.syncRoomMonitoringSnapshot();
|
|
843
|
+
await this.recoverStateIfNeeded();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
777
847
|
const secret = this.env.JWT_USER_SECRET;
|
|
778
848
|
if (!secret) {
|
|
779
849
|
this.safeSend(ws, { type: 'error', code: 'SERVER_ERROR', message: 'JWT secret not configured' });
|
|
@@ -782,14 +852,15 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
782
852
|
}
|
|
783
853
|
|
|
784
854
|
try {
|
|
855
|
+
const accessToken = token as string;
|
|
785
856
|
const headers = new Headers();
|
|
786
857
|
if (meta.ip) headers.set('CF-Connecting-IP', meta.ip);
|
|
787
858
|
if (meta.userAgent) headers.set('User-Agent', meta.userAgent);
|
|
788
|
-
headers.set('Authorization', `Bearer ${
|
|
859
|
+
headers.set('Authorization', `Bearer ${accessToken}`);
|
|
789
860
|
const { resolveAuthContextFromToken } = await import('../middleware/auth.js');
|
|
790
861
|
const auth = await resolveAuthContextFromToken(
|
|
791
862
|
this.env,
|
|
792
|
-
|
|
863
|
+
accessToken,
|
|
793
864
|
new Request('http://internal/api/room/auth', { headers }),
|
|
794
865
|
);
|
|
795
866
|
meta.authenticated = true;
|
|
@@ -891,7 +962,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
891
962
|
}
|
|
892
963
|
if (joinAccess && this.roomId) {
|
|
893
964
|
try {
|
|
894
|
-
const
|
|
965
|
+
const accessCtx = await this.buildHandlerContext();
|
|
966
|
+
const allowed = await Promise.resolve(joinAccess(this.buildAuthFromMeta(meta), this.roomId, accessCtx));
|
|
895
967
|
if (!allowed) {
|
|
896
968
|
this.safeSend(ws, {
|
|
897
969
|
type: 'error',
|
|
@@ -901,11 +973,19 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
901
973
|
ws.close(4003, 'Join denied');
|
|
902
974
|
return;
|
|
903
975
|
}
|
|
904
|
-
} 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
|
+
});
|
|
905
985
|
this.safeSend(ws, {
|
|
906
986
|
type: 'error',
|
|
907
987
|
code: 'JOIN_DENIED',
|
|
908
|
-
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}`,
|
|
909
989
|
});
|
|
910
990
|
ws.close(4003, 'Join denied');
|
|
911
991
|
return;
|
|
@@ -1040,11 +1120,13 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1040
1120
|
}
|
|
1041
1121
|
if (actionAccess && this.roomId) {
|
|
1042
1122
|
try {
|
|
1123
|
+
const accessCtx = await this.buildHandlerContext();
|
|
1043
1124
|
const allowed = await Promise.resolve(actionAccess(
|
|
1044
1125
|
this.buildAuthFromMeta(meta),
|
|
1045
1126
|
this.roomId,
|
|
1046
1127
|
actionType,
|
|
1047
1128
|
payload,
|
|
1129
|
+
accessCtx,
|
|
1048
1130
|
));
|
|
1049
1131
|
if (!allowed) {
|
|
1050
1132
|
this.safeSend(ws, {
|
|
@@ -1329,8 +1411,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1329
1411
|
env: this.env as never,
|
|
1330
1412
|
executionCtx: this.ctx as never,
|
|
1331
1413
|
serviceKey: resolveRootServiceKey(this.config, this.env as never),
|
|
1332
|
-
//
|
|
1333
|
-
|
|
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.
|
|
1334
1416
|
});
|
|
1335
1417
|
return {
|
|
1336
1418
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -1345,7 +1427,8 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
|
1345
1427
|
userId: meta.userId!,
|
|
1346
1428
|
connectionId: meta.connectionId,
|
|
1347
1429
|
role: meta.role,
|
|
1348
|
-
|
|
1430
|
+
auth: this.buildAuthFromMeta(meta),
|
|
1431
|
+
} as RoomSender;
|
|
1349
1432
|
}
|
|
1350
1433
|
|
|
1351
1434
|
protected buildAuthFromMeta(meta: RoomWSMeta): SharedAuthContext {
|
|
@@ -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
|
*/
|