@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
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Workers cannot use SMTP directly. Instead, we use HTTP REST API-based
|
|
5
5
|
* external email services via a common interface.
|
|
6
6
|
*
|
|
7
|
-
* Supported: Resend (default), SendGrid, Mailgun, AWS SES
|
|
7
|
+
* Supported: Resend (default), SendGrid, Mailgun, AWS SES, Cloudflare Email Service
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
// ─── Interface ───
|
|
@@ -26,6 +26,24 @@ export interface EmailProvider {
|
|
|
26
26
|
|
|
27
27
|
interface EmailProviderEnv {
|
|
28
28
|
EDGEBASE_EMAIL_API_URL?: string;
|
|
29
|
+
EDGEBASE_EMAIL_CLOUDFLARE_API_TOKEN?: string;
|
|
30
|
+
EDGEBASE_EMAIL_CLOUDFLARE_ACCOUNT_ID?: string;
|
|
31
|
+
EDGEBASE_EMAIL_CLOUDFLARE_BINDING?: string;
|
|
32
|
+
EMAIL?: CloudflareSendEmailBinding;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CloudflareSendEmailBinding {
|
|
36
|
+
send(message: {
|
|
37
|
+
to: string;
|
|
38
|
+
from: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
html?: string;
|
|
41
|
+
text?: string;
|
|
42
|
+
}): Promise<{ messageId?: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isCloudflareSendEmailBinding(value: unknown): value is CloudflareSendEmailBinding {
|
|
46
|
+
return !!value && typeof value === 'object' && typeof (value as { send?: unknown }).send === 'function';
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
const encoder = new TextEncoder();
|
|
@@ -331,14 +349,83 @@ export class SESProvider implements EmailProvider {
|
|
|
331
349
|
}
|
|
332
350
|
}
|
|
333
351
|
|
|
352
|
+
// ─── Cloudflare Email Service Provider ───
|
|
353
|
+
|
|
354
|
+
export class CloudflareEmailProvider implements EmailProvider {
|
|
355
|
+
private apiBaseUrl: string;
|
|
356
|
+
|
|
357
|
+
constructor(
|
|
358
|
+
private options: {
|
|
359
|
+
from: string;
|
|
360
|
+
apiKey?: string;
|
|
361
|
+
accountId?: string;
|
|
362
|
+
binding?: CloudflareSendEmailBinding;
|
|
363
|
+
apiBaseUrl?: string;
|
|
364
|
+
},
|
|
365
|
+
) {
|
|
366
|
+
this.apiBaseUrl = options.apiBaseUrl?.replace(/\/$/, '') ?? 'https://api.cloudflare.com/client/v4';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async send(options: EmailSendOptions): Promise<EmailSendResult> {
|
|
370
|
+
if (this.options.binding) {
|
|
371
|
+
try {
|
|
372
|
+
const result = await this.options.binding.send({
|
|
373
|
+
to: options.to,
|
|
374
|
+
from: this.options.from,
|
|
375
|
+
subject: options.subject,
|
|
376
|
+
html: options.html,
|
|
377
|
+
});
|
|
378
|
+
return { success: true, messageId: result.messageId };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
console.error('[EmailProvider:Cloudflare] Workers binding failed:', err);
|
|
381
|
+
return { success: false };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!this.options.apiKey || !this.options.accountId) {
|
|
386
|
+
console.error(
|
|
387
|
+
'[EmailProvider:Cloudflare] accountId and apiKey are required when a Workers send_email binding is not available.',
|
|
388
|
+
);
|
|
389
|
+
return { success: false };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const resp = await fetch(
|
|
393
|
+
`${this.apiBaseUrl}/accounts/${encodeURIComponent(this.options.accountId)}/email/sending/send`,
|
|
394
|
+
{
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: {
|
|
397
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
398
|
+
'Content-Type': 'application/json',
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
to: options.to,
|
|
402
|
+
from: this.options.from,
|
|
403
|
+
subject: options.subject,
|
|
404
|
+
html: options.html,
|
|
405
|
+
}),
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
if (!resp.ok) {
|
|
410
|
+
const text = await resp.text();
|
|
411
|
+
console.error('[EmailProvider:Cloudflare] Failed:', resp.status, text);
|
|
412
|
+
return { success: false };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { success: true };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
334
419
|
// ─── Factory ───
|
|
335
420
|
|
|
336
421
|
export interface EmailConfig {
|
|
337
|
-
provider: 'resend' | 'sendgrid' | 'mailgun' | 'ses';
|
|
338
|
-
apiKey
|
|
422
|
+
provider: 'resend' | 'sendgrid' | 'mailgun' | 'ses' | 'cloudflare';
|
|
423
|
+
apiKey?: string; // SES uses accessKeyId:secretAccessKey[:sessionToken]
|
|
339
424
|
from: string;
|
|
340
425
|
domain?: string; // Mailgun domain
|
|
341
426
|
region?: string; // SES region
|
|
427
|
+
accountId?: string; // Cloudflare REST API account ID
|
|
428
|
+
binding?: string; // Cloudflare Workers send_email binding name
|
|
342
429
|
}
|
|
343
430
|
|
|
344
431
|
/**
|
|
@@ -358,22 +445,45 @@ export function createEmailProvider(
|
|
|
358
445
|
return null;
|
|
359
446
|
}
|
|
360
447
|
|
|
361
|
-
if (!config.
|
|
362
|
-
console.warn('[EmailProvider]
|
|
448
|
+
if (!config.from) {
|
|
449
|
+
console.warn('[EmailProvider] from is required. Email features disabled.');
|
|
363
450
|
return null;
|
|
364
451
|
}
|
|
365
452
|
|
|
366
453
|
switch (config.provider) {
|
|
454
|
+
case 'cloudflare': {
|
|
455
|
+
const bindingName = config.binding
|
|
456
|
+
?? env?.EDGEBASE_EMAIL_CLOUDFLARE_BINDING?.trim()
|
|
457
|
+
?? 'EMAIL';
|
|
458
|
+
const envRecord = env as Record<string, unknown> | undefined;
|
|
459
|
+
const bindingCandidate = envRecord?.[bindingName];
|
|
460
|
+
const binding = isCloudflareSendEmailBinding(bindingCandidate)
|
|
461
|
+
? bindingCandidate
|
|
462
|
+
: undefined;
|
|
463
|
+
return new CloudflareEmailProvider({
|
|
464
|
+
from: config.from,
|
|
465
|
+
apiKey: config.apiKey ?? env?.EDGEBASE_EMAIL_CLOUDFLARE_API_TOKEN,
|
|
466
|
+
accountId: config.accountId ?? env?.EDGEBASE_EMAIL_CLOUDFLARE_ACCOUNT_ID,
|
|
467
|
+
binding,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
367
470
|
case 'resend':
|
|
471
|
+
if (!config.apiKey) break;
|
|
368
472
|
return new ResendProvider(config.apiKey, config.from);
|
|
369
473
|
case 'sendgrid':
|
|
474
|
+
if (!config.apiKey) break;
|
|
370
475
|
return new SendGridProvider(config.apiKey, config.from);
|
|
371
476
|
case 'mailgun':
|
|
477
|
+
if (!config.apiKey) break;
|
|
372
478
|
return new MailgunProvider(config.apiKey, config.from, config.domain);
|
|
373
479
|
case 'ses':
|
|
480
|
+
if (!config.apiKey) break;
|
|
374
481
|
return new SESProvider(config.apiKey, config.from, config.region);
|
|
375
482
|
default:
|
|
376
483
|
console.warn(`[EmailProvider] Unknown provider: ${config.provider}. Email features disabled.`);
|
|
377
484
|
return null;
|
|
378
485
|
}
|
|
486
|
+
|
|
487
|
+
console.warn('[EmailProvider] apiKey and from are required. Email features disabled.');
|
|
488
|
+
return null;
|
|
379
489
|
}
|
package/src/lib/functions.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { generateId } from './uuid.js';
|
|
|
41
41
|
import { DbRef, TableRef, DefaultDbApi, HttpClient, ContextManager } from '@edge-base/core';
|
|
42
42
|
import { InternalHttpTransport } from './internal-transport.js';
|
|
43
43
|
import { executeProviderAwareSql } from './provider-aware-sql.js';
|
|
44
|
+
import { createEmailProvider } from './email-provider.js';
|
|
44
45
|
|
|
45
46
|
// ─── Function Context Types ───
|
|
46
47
|
|
|
@@ -69,6 +70,15 @@ export interface AdminAuthContext {
|
|
|
69
70
|
revokeAllSessions(userId: string): Promise<void>;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
export interface FunctionEmailProxy {
|
|
74
|
+
send(options: {
|
|
75
|
+
to: string;
|
|
76
|
+
subject: string;
|
|
77
|
+
html?: string;
|
|
78
|
+
text?: string;
|
|
79
|
+
}): Promise<{ success: boolean; messageId?: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
/**
|
|
73
83
|
* AdminEdgeBase-shaped context object injected as context.admin.
|
|
74
84
|
*
|
|
@@ -186,6 +196,8 @@ export interface FunctionPushProxy {
|
|
|
186
196
|
|
|
187
197
|
/** Storage proxy for App Functions — wraps R2Bucket with convenience methods. */
|
|
188
198
|
export interface FunctionStorageProxy {
|
|
199
|
+
/** Return a proxy scoped to a named storage bucket. */
|
|
200
|
+
bucket(bucket: string): FunctionStorageProxy;
|
|
189
201
|
put(
|
|
190
202
|
key: string,
|
|
191
203
|
value: ReadableStream | ArrayBuffer | string,
|
|
@@ -199,6 +211,10 @@ export interface FunctionStorageProxy {
|
|
|
199
211
|
} | null>;
|
|
200
212
|
delete(key: string): Promise<void>;
|
|
201
213
|
getSignedUrl(key: string, options?: { expiresIn?: number }): Promise<string>;
|
|
214
|
+
getSignedUploadUrl(
|
|
215
|
+
key: string,
|
|
216
|
+
options?: { expiresIn?: number; maxBytes?: number | null },
|
|
217
|
+
): Promise<{ url: string; expiresAt: string; maxBytes: number | null }>;
|
|
202
218
|
list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{
|
|
203
219
|
keys: Array<{ key: string; size: number; contentType: string }>;
|
|
204
220
|
cursor?: string;
|
|
@@ -357,12 +373,29 @@ export interface FunctionContext {
|
|
|
357
373
|
data?: { before?: Record<string, unknown>; after?: Record<string, unknown> };
|
|
358
374
|
before?: Record<string, unknown>;
|
|
359
375
|
after?: Record<string, unknown>;
|
|
376
|
+
/** Configured server-side email sender, when an EdgeBase email provider is available. */
|
|
377
|
+
email?: FunctionEmailProxy;
|
|
378
|
+
/** Environment variables, secrets, and bindings available to the Worker. */
|
|
379
|
+
env?: Record<string, unknown>;
|
|
360
380
|
storage?: FunctionStorageProxy; // Available when R2 binding present
|
|
361
381
|
analytics?: unknown; // AnalyticsAdapter when ANALYTICS_APP binding present
|
|
362
382
|
/** Plugin-specific config from edgebase.config.ts plugins section. */
|
|
363
383
|
pluginConfig?: Record<string, unknown>;
|
|
364
384
|
}
|
|
365
385
|
|
|
386
|
+
function escapeEmailHtml(value: string): string {
|
|
387
|
+
return value
|
|
388
|
+
.replace(/&/g, '&')
|
|
389
|
+
.replace(/</g, '<')
|
|
390
|
+
.replace(/>/g, '>')
|
|
391
|
+
.replace(/"/g, '"')
|
|
392
|
+
.replace(/'/g, ''');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function textEmailToHtml(text: string): string {
|
|
396
|
+
return `<pre style="font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; white-space: pre-wrap;">${escapeEmailHtml(text)}</pre>`;
|
|
397
|
+
}
|
|
398
|
+
|
|
366
399
|
// ─── Function Registry ───
|
|
367
400
|
|
|
368
401
|
/**
|
|
@@ -693,6 +726,19 @@ export function wrapMethodExport(
|
|
|
693
726
|
method: string,
|
|
694
727
|
runtimeTrigger?: { path?: string },
|
|
695
728
|
): FunctionDefinition {
|
|
729
|
+
if (
|
|
730
|
+
method === '*' &&
|
|
731
|
+
handler &&
|
|
732
|
+
typeof handler === 'object' &&
|
|
733
|
+
'handler' in handler &&
|
|
734
|
+
'trigger' in handler &&
|
|
735
|
+
handler.trigger &&
|
|
736
|
+
typeof handler.trigger === 'object' &&
|
|
737
|
+
'type' in handler.trigger
|
|
738
|
+
) {
|
|
739
|
+
return handler as FunctionDefinition;
|
|
740
|
+
}
|
|
741
|
+
|
|
696
742
|
// handler can be: raw function, or { handler, captcha? } from defineFunction()
|
|
697
743
|
let fn: (ctx: unknown) => Promise<unknown>;
|
|
698
744
|
let captcha: boolean | undefined;
|
|
@@ -1096,6 +1142,19 @@ export function buildFunctionContext(options: BuildFunctionContextOptions): Func
|
|
|
1096
1142
|
executionCtx: options.executionCtx,
|
|
1097
1143
|
preferDirectDo: options.preferDirectDoDb,
|
|
1098
1144
|
});
|
|
1145
|
+
const emailProvider = createEmailProvider(options.config.email, options.env);
|
|
1146
|
+
const email: FunctionEmailProxy | undefined = emailProvider
|
|
1147
|
+
? {
|
|
1148
|
+
async send(message) {
|
|
1149
|
+
const to = message.to.trim();
|
|
1150
|
+
const subject = message.subject.trim();
|
|
1151
|
+
if (!to) throw new Error('email.send() requires a recipient.');
|
|
1152
|
+
if (!subject) throw new Error('email.send() requires a subject.');
|
|
1153
|
+
const html = message.html ?? textEmailToHtml(message.text ?? '');
|
|
1154
|
+
return emailProvider.send({ to, subject, html });
|
|
1155
|
+
},
|
|
1156
|
+
}
|
|
1157
|
+
: undefined;
|
|
1099
1158
|
const sqlProviderAware = (
|
|
1100
1159
|
namespace: string,
|
|
1101
1160
|
id: string | undefined,
|
|
@@ -1282,6 +1341,8 @@ export function buildFunctionContext(options: BuildFunctionContextOptions): Func
|
|
|
1282
1341
|
auth: options.auth,
|
|
1283
1342
|
db: admin.db,
|
|
1284
1343
|
admin,
|
|
1344
|
+
...(options.env ? { env: options.env as unknown as Record<string, unknown> } : {}),
|
|
1345
|
+
...(email ? { email } : {}),
|
|
1285
1346
|
params: options.params ?? {},
|
|
1286
1347
|
};
|
|
1287
1348
|
|
|
@@ -1826,6 +1887,10 @@ export function buildFunctionStorageProxy(
|
|
|
1826
1887
|
const prefix = (key: string) => `${bucket}/${key}`;
|
|
1827
1888
|
|
|
1828
1889
|
return {
|
|
1890
|
+
bucket(name) {
|
|
1891
|
+
return buildFunctionStorageProxy(r2, name, env, workerUrl);
|
|
1892
|
+
},
|
|
1893
|
+
|
|
1829
1894
|
async put(key, value, options) {
|
|
1830
1895
|
const httpMeta: R2PutOptions = {};
|
|
1831
1896
|
if (options?.contentType) httpMeta.httpMetadata = { contentType: options.contentType };
|
|
@@ -1853,11 +1918,36 @@ export function buildFunctionStorageProxy(
|
|
|
1853
1918
|
if (!secret) throw new Error('Signed URLs require JWT_USER_SECRET to be configured.');
|
|
1854
1919
|
const expiresIn = options?.expiresIn ?? 3600;
|
|
1855
1920
|
const expiresAt = Date.now() + expiresIn * 1000;
|
|
1856
|
-
const
|
|
1921
|
+
const obj = await r2.head(prefix(key));
|
|
1922
|
+
const token = await createSignedToken(key, bucket, expiresAt, secret, obj ? {
|
|
1923
|
+
file: {
|
|
1924
|
+
size: obj.size,
|
|
1925
|
+
contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
|
|
1926
|
+
etag: obj.etag,
|
|
1927
|
+
uploadedAt: obj.uploaded?.toISOString(),
|
|
1928
|
+
},
|
|
1929
|
+
} : undefined);
|
|
1857
1930
|
const base = workerUrl ?? 'http://localhost:8787';
|
|
1858
1931
|
return `${base}/api/storage/${encodeURIComponent(bucket)}/${key}?token=${token}`;
|
|
1859
1932
|
},
|
|
1860
1933
|
|
|
1934
|
+
async getSignedUploadUrl(key, options) {
|
|
1935
|
+
const secret = (env as unknown as Record<string, string>).JWT_USER_SECRET;
|
|
1936
|
+
if (!secret) throw new Error('Signed upload URLs require JWT_USER_SECRET to be configured.');
|
|
1937
|
+
const expiresIn = options?.expiresIn ?? 1800;
|
|
1938
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
1939
|
+
const maxBytes = typeof options?.maxBytes === 'number' && Number.isFinite(options.maxBytes)
|
|
1940
|
+
? Math.max(0, Math.trunc(options.maxBytes))
|
|
1941
|
+
: null;
|
|
1942
|
+
const token = await createSignedToken(key, bucket, expiresAt, secret, maxBytes);
|
|
1943
|
+
const base = workerUrl ?? 'http://localhost:8787';
|
|
1944
|
+
return {
|
|
1945
|
+
url: `${base}/api/storage/${encodeURIComponent(bucket)}/upload?token=${token}&key=${encodeURIComponent(key)}`,
|
|
1946
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
1947
|
+
maxBytes,
|
|
1948
|
+
};
|
|
1949
|
+
},
|
|
1950
|
+
|
|
1861
1951
|
async list(options) {
|
|
1862
1952
|
const r2Options: R2ListOptions = {};
|
|
1863
1953
|
if (options?.prefix) r2Options.prefix = prefix(options.prefix);
|
|
@@ -11,6 +11,7 @@ import type { EdgeBaseConfig } from '@edge-base/shared';
|
|
|
11
11
|
import {
|
|
12
12
|
callDO,
|
|
13
13
|
formatDbTargetValidationIssue,
|
|
14
|
+
getD1BindingName,
|
|
14
15
|
getDbDoName,
|
|
15
16
|
resolveDbTarget,
|
|
16
17
|
shouldRouteToD1,
|
|
@@ -94,6 +95,12 @@ function parsePath(
|
|
|
94
95
|
return { namespace, instanceId, tableName, directPath };
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
function hasD1Binding(env: Env | undefined, namespace: string): boolean {
|
|
99
|
+
if (!env) return false;
|
|
100
|
+
const bindingName = getD1BindingName(namespace);
|
|
101
|
+
return Boolean((env as unknown as Record<string, unknown>)[bindingName]);
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
export class InternalHttpTransport implements HttpTransport {
|
|
98
105
|
private readonly databaseNamespace: DurableObjectNamespace;
|
|
99
106
|
private readonly config: EdgeBaseConfig;
|
|
@@ -196,7 +203,14 @@ export class InternalHttpTransport implements HttpTransport {
|
|
|
196
203
|
const httpMethod = method === 'PUT' ? 'PATCH' : method; // normalize PUT → PATCH
|
|
197
204
|
|
|
198
205
|
// 1. D1 route
|
|
199
|
-
|
|
206
|
+
const shouldUseD1 =
|
|
207
|
+
!this.preferDirectDo
|
|
208
|
+
&& !dynamic
|
|
209
|
+
&& shouldRouteToD1(namespace, this.config)
|
|
210
|
+
&& this.env
|
|
211
|
+
&& (provider === 'd1' || hasD1Binding(this.env, namespace));
|
|
212
|
+
|
|
213
|
+
if (shouldUseD1) {
|
|
200
214
|
return this.requestViaD1Handler(httpMethod, namespace, normalizedInstanceId, tableName, directPath, headers, query, body);
|
|
201
215
|
}
|
|
202
216
|
|
package/src/lib/jwt.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface RefreshTokenPayload {
|
|
|
22
22
|
sub: string; // userId
|
|
23
23
|
type: 'refresh';
|
|
24
24
|
jti?: string; // unique session ID for token uniqueness
|
|
25
|
+
nonce?: string; // per-token randomness while preserving session jti
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export interface AdminTokenPayload {
|
|
@@ -106,6 +107,7 @@ export async function signRefreshToken(
|
|
|
106
107
|
): Promise<string> {
|
|
107
108
|
const builder = new SignJWT({
|
|
108
109
|
type: 'refresh',
|
|
110
|
+
nonce: payload.nonce ?? crypto.randomUUID(),
|
|
109
111
|
})
|
|
110
112
|
.setProtectedHeader({ alg: ALGORITHM })
|
|
111
113
|
.setIssuedAt()
|
package/src/routes/admin.ts
CHANGED
|
@@ -1539,7 +1539,14 @@ api.openapi(adminCreateSignedUrl, async (c) => {
|
|
|
1539
1539
|
|
|
1540
1540
|
const expiresInMs = parseDuration(body.expiresIn || '1h');
|
|
1541
1541
|
const expiresAt = Date.now() + expiresInMs;
|
|
1542
|
-
const token = await createSignedToken(body.key, bucketName, expiresAt, secret
|
|
1542
|
+
const token = await createSignedToken(body.key, bucketName, expiresAt, secret, {
|
|
1543
|
+
file: {
|
|
1544
|
+
size: obj.size,
|
|
1545
|
+
contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
|
|
1546
|
+
etag: obj.etag,
|
|
1547
|
+
uploadedAt: obj.uploaded?.toISOString(),
|
|
1548
|
+
},
|
|
1549
|
+
});
|
|
1543
1550
|
|
|
1544
1551
|
// Build signed URL using the public storage endpoint
|
|
1545
1552
|
const url = new URL(c.req.url);
|