@edge-base/server 0.2.9 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-build/_app/immutable/chunks/BENT96gS.js +1 -0
- package/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BR-X1vqg.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BYTXdmFz.js} +1 -1
- package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → C-_7bS5w.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → CCB6XzFZ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{39YMyjjq.js → CWTeyOdB.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DllVADtt.js → CX5CmbTG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{0M5txo3l.js → CdgSuiqt.js} +3 -3
- package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Cuny2nPU.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → D15DP4DQ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DBkor_jM.js → DAzRCtch.js} +1 -1
- package/admin-build/_app/immutable/chunks/DYF4byEY.js +1 -0
- package/admin-build/_app/immutable/chunks/{DBniE7GN.js → Dcey5WNo.js} +1 -1
- package/admin-build/_app/immutable/chunks/{C5nMidnK.js → HK54RDdv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → gUtOgqgM.js} +1 -1
- package/admin-build/_app/immutable/chunks/{_xa30BOD.js → pnqPwnFK.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.DwslmBQo.js} +2 -2
- package/admin-build/_app/immutable/entry/start.Bi51W3Uj.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.SSaZIC3t.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.CqSv9lq5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.BoQz_717.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.B-yS_gft.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.WsVtLoVx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.TbqRQJKh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.cldePwj3.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.Bec037-f.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.Dd0yTYhN.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.CoiqH0lF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.BDXxM1Qi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.lo9mTPiR.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.B7kbGqh_.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.BGm1TcfO.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.VOtCuTkf.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.C2Uxx-kO.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.BxdEwsS3.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.CVu2qoCv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.DqYlpX7Z.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.emgbpP8y.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.BVPSPK68.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.BsDQQ16z.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.uc8joqGb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.D4CYqQrE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.DvUuXrVU.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.BqTZ0Uoq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.DAO3--6W.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.CBbGl_Wo.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.s-I8cKAh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.Ct6B-y3f.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.Dok_ZoeZ.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +498 -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__/error-format.test.ts +21 -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__/postgres-api-consistency.test.ts +240 -0
- package/src/__tests__/postgres-batch-compat.test.ts +33 -27
- package/src/__tests__/postgres-batch.test.ts +394 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
- package/src/__tests__/postgres-transact.test.ts +148 -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/database-do.ts +455 -15
- 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/d1-handler.ts +416 -50
- package/src/lib/email-provider.ts +115 -5
- package/src/lib/errors.ts +8 -4
- package/src/lib/functions.ts +91 -1
- package/src/lib/internal-transport.ts +25 -1
- package/src/lib/jwt.ts +2 -0
- package/src/lib/postgres-handler.ts +562 -84
- package/src/routes/admin.ts +8 -1
- package/src/routes/auth.ts +169 -65
- package/src/routes/room.ts +36 -2
- package/src/routes/storage.ts +446 -62
- package/src/routes/tables.ts +140 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edge-base/server",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "EdgeBase runtime assets consumed by the EdgeBase CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"jose": "^6.0.0",
|
|
35
35
|
"pg": "^8.16.3",
|
|
36
36
|
"zod": "^4.3.6",
|
|
37
|
-
"@edge-base/core": "0.2
|
|
38
|
-
"@edge-base/shared": "0.2
|
|
37
|
+
"@edge-base/core": "0.3.2",
|
|
38
|
+
"@edge-base/shared": "0.3.2"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@cloudflare/vitest-pool-workers": "^0.8.71",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { AuthDb } from '../lib/auth-db-adapter.js';
|
|
3
|
+
import {
|
|
4
|
+
createEmailToken,
|
|
5
|
+
deleteEmailToken,
|
|
6
|
+
getEmailToken,
|
|
7
|
+
getEmailTokenByType,
|
|
8
|
+
} from '../lib/auth-d1-service.js';
|
|
9
|
+
import {
|
|
10
|
+
authSecretLookupKeys,
|
|
11
|
+
hashAuthSecret,
|
|
12
|
+
hashOtpSecret,
|
|
13
|
+
verifyAuthSecret,
|
|
14
|
+
verifyOtpSecret,
|
|
15
|
+
} from '../lib/auth-token.js';
|
|
16
|
+
|
|
17
|
+
class MemoryAuthDb implements AuthDb {
|
|
18
|
+
readonly dialect = 'sqlite' as const;
|
|
19
|
+
rows: Array<Record<string, unknown>> = [];
|
|
20
|
+
|
|
21
|
+
async query<T = Record<string, unknown>>(): Promise<T[]> {
|
|
22
|
+
return this.rows as T[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async first<T = Record<string, unknown>>(_sql: string, params: unknown[] = []): Promise<T | null> {
|
|
26
|
+
const type = _sql.includes('AND type = ?') ? params[params.length - 1] : undefined;
|
|
27
|
+
const tokenKeys = type ? params.slice(0, -1) : params;
|
|
28
|
+
const row = this.rows.find((candidate) => {
|
|
29
|
+
return tokenKeys.includes(candidate.token) && (!type || candidate.type === type);
|
|
30
|
+
});
|
|
31
|
+
return (row ?? null) as T | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async run(sql: string, params: unknown[] = []): Promise<void> {
|
|
35
|
+
if (sql.includes('INSERT INTO _email_tokens')) {
|
|
36
|
+
const [token, userId, type, expiresAt, createdAt] = params;
|
|
37
|
+
this.rows.push({ token, userId, type, expiresAt, createdAt });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sql.includes('DELETE FROM _email_tokens WHERE token IN')) {
|
|
41
|
+
const keys = new Set(params);
|
|
42
|
+
this.rows = this.rows.filter((row) => !keys.has(row.token));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unexpected SQL in MemoryAuthDb: ${sql}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async batch(): Promise<void> {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('auth-token helpers', () => {
|
|
52
|
+
it('hashes auth secrets and verifies them without storing the raw value', async () => {
|
|
53
|
+
const hash = await hashAuthSecret('123456');
|
|
54
|
+
|
|
55
|
+
expect(hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
56
|
+
expect(hash).not.toBe('123456');
|
|
57
|
+
await expect(verifyAuthSecret('123456', hash)).resolves.toBe(true);
|
|
58
|
+
await expect(verifyAuthSecret('000000', hash)).resolves.toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('hashes short OTP secrets with a server-keyed HMAC', async () => {
|
|
62
|
+
const hash = await hashOtpSecret('123456', 'server-secret-a');
|
|
63
|
+
|
|
64
|
+
expect(hash).toMatch(/^hmac-sha256:[a-f0-9]{64}$/);
|
|
65
|
+
expect(hash).not.toBe('123456');
|
|
66
|
+
expect(hash).not.toBe(await hashAuthSecret('123456'));
|
|
67
|
+
await expect(verifyOtpSecret('123456', hash, 'server-secret-a')).resolves.toBe(true);
|
|
68
|
+
await expect(verifyOtpSecret('000000', hash, 'server-secret-a')).resolves.toBe(false);
|
|
69
|
+
await expect(verifyOtpSecret('123456', hash, 'server-secret-b')).resolves.toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps short-lived legacy SHA-256 OTP hashes verifiable', async () => {
|
|
73
|
+
const legacyHash = await hashAuthSecret('123456');
|
|
74
|
+
|
|
75
|
+
await expect(verifyOtpSecret('123456', legacyHash, 'server-secret-a')).resolves.toBe(true);
|
|
76
|
+
await expect(verifyOtpSecret('000000', legacyHash, 'server-secret-a')).resolves.toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('builds hashed and legacy lookup keys for auth secrets', async () => {
|
|
80
|
+
const keys = await authSecretLookupKeys('raw-magic-link-token');
|
|
81
|
+
|
|
82
|
+
expect(keys).toHaveLength(2);
|
|
83
|
+
expect(keys[0]).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
84
|
+
expect(keys[1]).toBe('raw-magic-link-token');
|
|
85
|
+
await expect(authSecretLookupKeys(keys[0])).resolves.toEqual([keys[0]]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('email token storage', () => {
|
|
90
|
+
it('stores hashed email tokens while allowing raw-token lookup and deletion', async () => {
|
|
91
|
+
const db = new MemoryAuthDb();
|
|
92
|
+
const expiresAt = new Date(Date.now() + 60_000).toISOString();
|
|
93
|
+
|
|
94
|
+
await createEmailToken(db, {
|
|
95
|
+
token: 'raw-magic-link-token',
|
|
96
|
+
userId: 'user-1',
|
|
97
|
+
type: 'magic-link',
|
|
98
|
+
expiresAt,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(db.rows).toHaveLength(1);
|
|
102
|
+
expect(db.rows[0].token).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
103
|
+
expect(db.rows[0].token).not.toBe('raw-magic-link-token');
|
|
104
|
+
|
|
105
|
+
const row = await getEmailToken(db, 'raw-magic-link-token');
|
|
106
|
+
expect(row?.userId).toBe('user-1');
|
|
107
|
+
|
|
108
|
+
await deleteEmailToken(db, 'raw-magic-link-token');
|
|
109
|
+
expect(db.rows).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('still accepts and deletes legacy raw email tokens', async () => {
|
|
113
|
+
const db = new MemoryAuthDb();
|
|
114
|
+
db.rows.push({
|
|
115
|
+
token: 'legacy-verify-token',
|
|
116
|
+
userId: 'user-2',
|
|
117
|
+
type: 'verify',
|
|
118
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
119
|
+
createdAt: new Date().toISOString(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const row = await getEmailTokenByType(db, 'legacy-verify-token', 'verify');
|
|
123
|
+
expect(row?.userId).toBe('user-2');
|
|
124
|
+
|
|
125
|
+
await deleteEmailToken(db, 'legacy-verify-token');
|
|
126
|
+
expect(db.rows).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('cleans up expired hashed tokens on lookup', async () => {
|
|
130
|
+
const db = new MemoryAuthDb();
|
|
131
|
+
await createEmailToken(db, {
|
|
132
|
+
token: 'expired-token',
|
|
133
|
+
userId: 'user-3',
|
|
134
|
+
type: 'password-reset',
|
|
135
|
+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const row = await getEmailToken(db, 'expired-token');
|
|
139
|
+
expect(row).toBeNull();
|
|
140
|
+
expect(db.rows).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -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(
|
|
@@ -263,6 +263,27 @@ describe('normalizeDatabaseError', () => {
|
|
|
263
263
|
expect(err?.slug).toBe('constraint-failed');
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
+
// PostgreSQL uses different constraint phrasings than SQLite/D1; the same
|
|
267
|
+
// normalizer must map them to the same slugs/status codes for provider parity.
|
|
268
|
+
it('maps PostgreSQL foreign key violations to the same 400 slug', () => {
|
|
269
|
+
const err = normalizeDatabaseError(
|
|
270
|
+
new Error('insert or update on table "posts" violates foreign key constraint "posts_author_id_fkey"'),
|
|
271
|
+
);
|
|
272
|
+
expect(err).toBeInstanceOf(EdgeBaseError);
|
|
273
|
+
expect(err?.code).toBe(400);
|
|
274
|
+
expect(err?.slug).toBe('foreign-key-failed');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('maps PostgreSQL not-null violations to the constraint slug and extracts the column', () => {
|
|
278
|
+
const err = normalizeDatabaseError(
|
|
279
|
+
new Error('null value in column "email" of relation "users" violates not-null constraint'),
|
|
280
|
+
);
|
|
281
|
+
expect(err).toBeInstanceOf(EdgeBaseError);
|
|
282
|
+
expect(err?.code).toBe(400);
|
|
283
|
+
expect(err?.message).toContain("'email'");
|
|
284
|
+
expect(err?.slug).toBe('constraint-failed');
|
|
285
|
+
});
|
|
286
|
+
|
|
266
287
|
it('returns null for blank string inputs', () => {
|
|
267
288
|
expect(normalizeDatabaseError(' ')).toBeNull();
|
|
268
289
|
});
|
|
@@ -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 () => {
|