@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.
Files changed (89) hide show
  1. package/admin-build/_app/immutable/chunks/BENT96gS.js +1 -0
  2. package/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BR-X1vqg.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BYTXdmFz.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → C-_7bS5w.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → CCB6XzFZ.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{39YMyjjq.js → CWTeyOdB.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{DllVADtt.js → CX5CmbTG.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{0M5txo3l.js → CdgSuiqt.js} +3 -3
  9. package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Cuny2nPU.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → D15DP4DQ.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{DBkor_jM.js → DAzRCtch.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/DYF4byEY.js +1 -0
  13. package/admin-build/_app/immutable/chunks/{DBniE7GN.js → Dcey5WNo.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{C5nMidnK.js → HK54RDdv.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → gUtOgqgM.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{_xa30BOD.js → pnqPwnFK.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.DwslmBQo.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Bi51W3Uj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.SSaZIC3t.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.CqSv9lq5.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.BoQz_717.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.B-yS_gft.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.WsVtLoVx.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.TbqRQJKh.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.cldePwj3.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.Bec037-f.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.Dd0yTYhN.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.CoiqH0lF.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.BDXxM1Qi.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.lo9mTPiR.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.B7kbGqh_.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.BGm1TcfO.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.VOtCuTkf.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.C2Uxx-kO.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.BxdEwsS3.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.CVu2qoCv.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.DqYlpX7Z.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.emgbpP8y.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.BVPSPK68.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.BsDQQ16z.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.uc8joqGb.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.D4CYqQrE.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.DvUuXrVU.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.BqTZ0Uoq.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.DAO3--6W.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.CBbGl_Wo.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.s-I8cKAh.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.Ct6B-y3f.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.Dok_ZoeZ.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +498 -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__/error-format.test.ts +21 -0
  57. package/src/__tests__/functions-context.test.ts +87 -0
  58. package/src/__tests__/functions-d1-proxy.test.ts +70 -0
  59. package/src/__tests__/functions-route.test.ts +15 -0
  60. package/src/__tests__/postgres-api-consistency.test.ts +240 -0
  61. package/src/__tests__/postgres-batch-compat.test.ts +33 -27
  62. package/src/__tests__/postgres-batch.test.ts +394 -0
  63. package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
  64. package/src/__tests__/postgres-transact.test.ts +148 -0
  65. package/src/__tests__/room-access-policy.test.ts +26 -0
  66. package/src/__tests__/room-handler-context.test.ts +2 -0
  67. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  68. package/src/durable-objects/database-do.ts +455 -15
  69. package/src/durable-objects/room-runtime-base.ts +16 -5
  70. package/src/durable-objects/rooms-do.ts +4 -1
  71. package/src/lib/auth-d1-service.ts +37 -7
  72. package/src/lib/auth-token.ts +66 -0
  73. package/src/lib/d1-handler.ts +416 -50
  74. package/src/lib/email-provider.ts +115 -5
  75. package/src/lib/errors.ts +8 -4
  76. package/src/lib/functions.ts +91 -1
  77. package/src/lib/internal-transport.ts +25 -1
  78. package/src/lib/jwt.ts +2 -0
  79. package/src/lib/postgres-handler.ts +562 -84
  80. package/src/routes/admin.ts +8 -1
  81. package/src/routes/auth.ts +169 -65
  82. package/src/routes/room.ts +36 -2
  83. package/src/routes/storage.ts +446 -62
  84. package/src/routes/tables.ts +140 -0
  85. package/src/types.ts +10 -0
  86. package/admin-build/_app/immutable/chunks/B0zJ5_Wk.js +0 -1
  87. package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
  88. package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
  89. 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.9",
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.9",
38
- "@edge-base/shared": "0.2.9"
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 () => {