@edge-base/server 0.1.5 → 0.2.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/assets/19.4Si2ZFC_.css +1 -0
- package/admin-build/_app/immutable/assets/{3.Dg81Pgmd.css → 3.BtHYobTg.css} +1 -1
- package/admin-build/_app/immutable/assets/SqlEditor.Bbp1RIk0.css +1 -0
- package/admin-build/_app/immutable/assets/TableSqlTab.yeNZfhgG.css +1 -0
- package/admin-build/_app/immutable/chunks/B0QyxC2M.js +128 -0
- package/admin-build/_app/immutable/chunks/{Bsp3uE8m.js → BCKr7yKd.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CWHVkYdi.js → BFs_qStz.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CKIubXVC.js → BTJcQFEp.js} +1 -1
- package/admin-build/_app/immutable/chunks/BY07qVPA.js +1 -0
- package/admin-build/_app/immutable/chunks/{Cn5ZQY9O.js → BcIUK2sk.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CKFIU4S8.js → BsFiK_FJ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{COYZ6F_d.js → CSGrwS7E.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DLc6L4xD.js → CqUxCvs_.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DLTo2HQr.js → D-x55wdW.js} +1 -1
- package/admin-build/_app/immutable/chunks/D755Tqat.js +1 -0
- package/admin-build/_app/immutable/chunks/{Jxx0jGlP.js → DjOEv9M9.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DLRcaFHo.js → DnLqc9L1.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BnlxEqP4.js → Dqk2TGNU.js} +1 -1
- package/admin-build/_app/immutable/chunks/{7FYfV8UU.js → k0CIJkw4.js} +1 -1
- package/admin-build/_app/immutable/chunks/lSpxLU5p.js +2 -0
- package/admin-build/_app/immutable/chunks/m9QZTyVV.js +1 -0
- package/admin-build/_app/immutable/entry/{app.C4kLStKR.js → app.BTsq3_xq.js} +2 -2
- package/admin-build/_app/immutable/entry/start.zXCirpgY.js +1 -0
- package/admin-build/_app/immutable/nodes/0.BZ00WDYH.js +1 -0
- package/admin-build/_app/immutable/nodes/{1.s1kW8gyv.js → 1.RzSJ3yyr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.Cr9ml-GD.js → 10.D-rsiquF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.DXTOyMEn.js → 11.l7-bgtFD.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.DzhitECA.js → 12.Dkq0H7B5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.U_1VNA5x.js → 13.DtK_4oRz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.x0SzmV0P.js → 14.BKo7-AMx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.BJI1Z1hk.js → 15.CQAj_6lq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.CUvRFqLW.js → 16.XVIG-Ffr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.sDFD-Vbm.js → 17.g6raZLCM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DdoTnAXc.js → 18.IQz6a3T6.js} +1 -1
- package/admin-build/_app/immutable/nodes/19.CAAZ8i8h.js +2 -0
- package/admin-build/_app/immutable/nodes/{20.fxudyPKp.js → 20.BPcX3KPj.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DoPabrY_.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.CY6ICoyn.js → 22.Br5AG_5Z.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.DmEaHsZw.js → 23.KjbrdXoE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.Cqerdln2.js → 24.C3n2-hgw.js} +1 -1
- package/admin-build/_app/immutable/nodes/25.SFDSBzHd.js +2 -0
- package/admin-build/_app/immutable/nodes/26.D95vui6E.js +1 -0
- package/admin-build/_app/immutable/nodes/{27.DAeKhnG9.js → 27.FgLgdjwB.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.D_lJZpNR.js → 28.B9sYYm1F.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.Dx7iQCpT.js → 29.DyqZ_wbN.js} +1 -1
- package/admin-build/_app/immutable/nodes/3.Bzo2yVIO.js +2 -0
- package/admin-build/_app/immutable/nodes/{30.DcO-bjQZ.js → 30.c1CiNwiS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.DesWAzIF.js → 31.CXty66Vh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.B2rm4_4a.js → 4.BgQaXZ27.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.4Os1aYeW.js → 5.BuJrHvxH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.BzR--5Dj.js → 6.CkBBC94k.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.QDC-47H1.js → 7.D2YBvNFM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.DDPWEc96.js → 8.D8qQWo_z.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.fyfVIJPa.js → 9.BLDLX5hV.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +6710 -5866
- package/package.json +2 -2
- package/src/__tests__/functions-route.test.ts +153 -0
- package/src/__tests__/internal-request.test.ts +5 -5
- package/src/__tests__/meta-export-coverage.test.ts +3 -2
- package/src/__tests__/meta-route-registration.test.ts +3 -2
- package/src/__tests__/openapi-coverage.test.ts +5 -1
- package/src/__tests__/pagination.test.ts +12 -8
- package/src/__tests__/postgres-dialect.test.ts +2 -2
- package/src/__tests__/query.test.ts +7 -7
- package/src/__tests__/rate-limit.test.ts +0 -1
- package/src/__tests__/room-handler-context.test.ts +31 -0
- package/src/__tests__/room-runtime-routing.test.ts +48 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +5 -4
- package/src/__tests__/smoke-skip-report.test.ts +3 -2
- package/src/durable-objects/database-do.ts +9 -4
- package/src/durable-objects/database-live-do.ts +22 -10
- package/src/durable-objects/logs-do.ts +2 -2
- package/src/durable-objects/rooms-do.ts +202 -0
- package/src/lib/auth-d1-service.ts +1 -1
- package/src/lib/auth-d1.ts +10 -0
- package/src/lib/d1-handler.ts +23 -4
- package/src/lib/internal-request.ts +5 -8
- package/src/lib/openapi.ts +1 -0
- package/src/lib/pagination.ts +3 -3
- package/src/lib/postgres-handler.ts +2 -2
- package/src/lib/query-engine.ts +2 -2
- package/src/middleware/rate-limit.ts +11 -11
- package/src/routes/admin.ts +30 -3
- package/src/routes/auth.ts +74 -33
- package/src/routes/room.ts +42 -0
- package/src/types.ts +6 -0
- package/admin-build/_app/immutable/assets/TableSqlTab.BHquaMBM.css +0 -1
- package/admin-build/_app/immutable/chunks/BPXFNSAT.js +0 -128
- package/admin-build/_app/immutable/chunks/DKjA1S1a.js +0 -1
- package/admin-build/_app/immutable/chunks/fBE8lw-R.js +0 -1
- package/admin-build/_app/immutable/chunks/gE9fQ_Ff.js +0 -2
- package/admin-build/_app/immutable/entry/start.CAAH6ztW.js +0 -1
- package/admin-build/_app/immutable/nodes/0.BywaJfpH.js +0 -1
- package/admin-build/_app/immutable/nodes/19.CUvRFqLW.js +0 -1
- package/admin-build/_app/immutable/nodes/21.N2QN0lbw.js +0 -1
- package/admin-build/_app/immutable/nodes/25.x3hL7Y-O.js +0 -2
- package/admin-build/_app/immutable/nodes/26.CEzfjTSO.js +0 -1
- package/admin-build/_app/immutable/nodes/3.CKC_yDnF.js +0 -2
|
@@ -573,11 +573,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
573
573
|
if (!canRead) continue;
|
|
574
574
|
|
|
575
575
|
let shouldSend = true;
|
|
576
|
-
if (
|
|
576
|
+
if (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) {
|
|
577
577
|
const filters = meta.channelFilters.get(batch.channel) || [];
|
|
578
578
|
const orFilters = meta.channelOrFilters.get(batch.channel) || [];
|
|
579
579
|
if (filters.length > 0 || orFilters.length > 0) {
|
|
580
|
-
shouldSend =
|
|
580
|
+
shouldSend = change.data
|
|
581
|
+
? evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters)
|
|
582
|
+
: true; // DELETE events (null data) pass filters
|
|
581
583
|
}
|
|
582
584
|
}
|
|
583
585
|
|
|
@@ -608,11 +610,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
608
610
|
if (!canRead) continue;
|
|
609
611
|
|
|
610
612
|
let shouldSend = true;
|
|
611
|
-
if (
|
|
613
|
+
if (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) {
|
|
612
614
|
const filters = meta.channelFilters.get(batch.channel) || [];
|
|
613
615
|
const orFilters = meta.channelOrFilters.get(batch.channel) || [];
|
|
614
616
|
if (filters.length > 0 || orFilters.length > 0) {
|
|
615
|
-
shouldSend =
|
|
617
|
+
shouldSend = change.data
|
|
618
|
+
? evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters)
|
|
619
|
+
: true; // DELETE events (null data) pass filters
|
|
616
620
|
}
|
|
617
621
|
}
|
|
618
622
|
|
|
@@ -727,11 +731,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
727
731
|
if (!canRead) continue;
|
|
728
732
|
|
|
729
733
|
let shouldSend = true;
|
|
730
|
-
if (
|
|
734
|
+
if ((meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) && msgChannel) {
|
|
731
735
|
const filters = meta.channelFilters.get(msgChannel) || [];
|
|
732
736
|
const orFilters = meta.channelOrFilters.get(msgChannel) || [];
|
|
733
737
|
if (filters.length > 0 || orFilters.length > 0) {
|
|
734
|
-
shouldSend =
|
|
738
|
+
shouldSend = eventData
|
|
739
|
+
? evaluateDatabaseLiveFilters(eventData, filters, orFilters)
|
|
740
|
+
: true; // DELETE events (null data) pass filters
|
|
735
741
|
}
|
|
736
742
|
}
|
|
737
743
|
|
|
@@ -820,11 +826,14 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
820
826
|
const result = await Promise.race([
|
|
821
827
|
Promise.resolve(rule(authCtx as Record<string, unknown> | null, row)),
|
|
822
828
|
new Promise<never>((_, reject) =>
|
|
823
|
-
setTimeout(() => reject(new Error('Database live row access timeout')),
|
|
829
|
+
setTimeout(() => reject(new Error('Database live row access timeout')), 500),
|
|
824
830
|
),
|
|
825
831
|
]);
|
|
826
832
|
return Boolean(result);
|
|
827
|
-
} catch {
|
|
833
|
+
} catch (e) {
|
|
834
|
+
if (e instanceof Error && e.message.includes('timeout')) {
|
|
835
|
+
console.warn('DatabaseLive: row access rule timed out after 500ms');
|
|
836
|
+
}
|
|
828
837
|
return false;
|
|
829
838
|
}
|
|
830
839
|
}
|
|
@@ -848,11 +857,14 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
848
857
|
const result = await Promise.race([
|
|
849
858
|
Promise.resolve(tableRules(authCtx as Record<string, unknown> | null, {})),
|
|
850
859
|
new Promise<never>((_, reject) =>
|
|
851
|
-
setTimeout(() => reject(new Error('Database live channel access timeout')),
|
|
860
|
+
setTimeout(() => reject(new Error('Database live channel access timeout')), 500),
|
|
852
861
|
),
|
|
853
862
|
]);
|
|
854
863
|
return Boolean(result);
|
|
855
|
-
} catch {
|
|
864
|
+
} catch (e) {
|
|
865
|
+
if (e instanceof Error && e.message.includes('timeout')) {
|
|
866
|
+
console.warn('DatabaseLive: channel access rule timed out after 500ms');
|
|
867
|
+
}
|
|
856
868
|
return false;
|
|
857
869
|
}
|
|
858
870
|
}
|
|
@@ -484,10 +484,10 @@ export class LogsDO extends DurableObject<LogsDOEnv> {
|
|
|
484
484
|
whereParts.push('status >= ?');
|
|
485
485
|
params.push(SERVER_ERROR_STATUS);
|
|
486
486
|
} else if (level === 'warn') {
|
|
487
|
-
whereParts.push('status >= ? AND status < ?');
|
|
487
|
+
whereParts.push('(status >= ? AND status < ? AND status != 304)');
|
|
488
488
|
params.push(300, SERVER_ERROR_STATUS);
|
|
489
489
|
} else if (level === 'info') {
|
|
490
|
-
whereParts.push('status >= ? AND status < ?');
|
|
490
|
+
whereParts.push('(status >= ? AND status < ? OR status = 304)');
|
|
491
491
|
params.push(200, 300);
|
|
492
492
|
}
|
|
493
493
|
|
|
@@ -115,6 +115,7 @@ const DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS = 30000;
|
|
|
115
115
|
const SIGNAL_DENIED = Symbol('rooms.signal.denied');
|
|
116
116
|
const MEDIA_DENIED = Symbol('rooms.media.denied');
|
|
117
117
|
const WEBSOCKET_OPEN = 1;
|
|
118
|
+
const CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY = 'cloudflareRealtimeKitMeetingId';
|
|
118
119
|
|
|
119
120
|
function computeStateDelta(
|
|
120
121
|
previous: Record<string, unknown>,
|
|
@@ -146,10 +147,16 @@ export class RoomsDO extends RoomRuntimeBaseDO {
|
|
|
146
147
|
private readonly memberRoles = new Map<string, string>();
|
|
147
148
|
private readonly memberMediaStates = new Map<string, RoomMemberMediaState>();
|
|
148
149
|
private readonly memberRealtimeSessions = new Map<string, RoomMemberRealtimeSession>();
|
|
150
|
+
private cloudflareRealtimeKitMeetingId: string | null = null;
|
|
151
|
+
private cloudflareRealtimeKitMeetingIdPromise: Promise<string> | null = null;
|
|
149
152
|
|
|
150
153
|
override async fetch(request: Request): Promise<Response> {
|
|
151
154
|
const url = new URL(request.url);
|
|
152
155
|
|
|
156
|
+
if (url.pathname === '/media/cloudflare_realtimekit/session' && request.method === 'POST') {
|
|
157
|
+
return this.handleCloudflareRealtimeKitSessionCreate(request, url);
|
|
158
|
+
}
|
|
159
|
+
|
|
153
160
|
if (url.pathname === '/media/realtime/session') {
|
|
154
161
|
if (request.method === 'POST') return this.handleRealtimeSessionCreate(request, url);
|
|
155
162
|
if (request.method === 'GET') return this.handleRealtimeSessionGet(request, url);
|
|
@@ -175,6 +182,53 @@ export class RoomsDO extends RoomRuntimeBaseDO {
|
|
|
175
182
|
return super.fetch(request);
|
|
176
183
|
}
|
|
177
184
|
|
|
185
|
+
private async handleCloudflareRealtimeKitSessionCreate(request: Request, url: URL): Promise<Response> {
|
|
186
|
+
try {
|
|
187
|
+
const body = await this.readJsonBody<{
|
|
188
|
+
connectionId?: string;
|
|
189
|
+
customParticipantId?: string;
|
|
190
|
+
name?: string;
|
|
191
|
+
picture?: string;
|
|
192
|
+
}>(request);
|
|
193
|
+
const { memberId, connectionId, meta } = await this.authenticateRealtimeRequest(
|
|
194
|
+
request,
|
|
195
|
+
url,
|
|
196
|
+
typeof body.connectionId === 'string' ? body.connectionId : undefined,
|
|
197
|
+
);
|
|
198
|
+
if (this.hasPublishedTracks(memberId)) {
|
|
199
|
+
return this.jsonResponse(409, {
|
|
200
|
+
code: 409,
|
|
201
|
+
message: 'Unpublish existing room media before creating a new Cloudflare RealtimeKit session',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const config = this.getCloudflareRealtimeKitConfig();
|
|
206
|
+
const meetingId = await this.ensureCloudflareRealtimeKitMeetingId(config);
|
|
207
|
+
const participant = await this.createCloudflareRealtimeKitParticipant(config, meetingId, {
|
|
208
|
+
customParticipantId: this.buildCloudflareRealtimeKitParticipantId(memberId, body.customParticipantId),
|
|
209
|
+
name: typeof body.name === 'string' && body.name.trim()
|
|
210
|
+
? body.name.trim()
|
|
211
|
+
: meta.auth?.email ?? meta.userId ?? memberId,
|
|
212
|
+
picture: typeof body.picture === 'string' && body.picture.trim() ? body.picture.trim() : undefined,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return this.jsonResponse(200, {
|
|
216
|
+
sessionId: participant.id,
|
|
217
|
+
meetingId,
|
|
218
|
+
participantId: participant.id,
|
|
219
|
+
authToken: participant.token,
|
|
220
|
+
presetName: participant.presetName ?? config.presetName,
|
|
221
|
+
connectionId,
|
|
222
|
+
reused: false,
|
|
223
|
+
});
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return this.jsonResponse(400, {
|
|
226
|
+
code: 400,
|
|
227
|
+
message: err instanceof Error ? err.message : 'Failed to create Cloudflare RealtimeKit session',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
178
232
|
private async handleRealtimeSessionCreate(request: Request, url: URL): Promise<Response> {
|
|
179
233
|
try {
|
|
180
234
|
const body = await this.readJsonBody<{
|
|
@@ -422,6 +476,154 @@ export class RoomsDO extends RoomRuntimeBaseDO {
|
|
|
422
476
|
return createCloudflareRealtimeClient(this.env as unknown as Env);
|
|
423
477
|
}
|
|
424
478
|
|
|
479
|
+
private getCloudflareRealtimeKitConfig(): {
|
|
480
|
+
accountId: string;
|
|
481
|
+
apiToken: string;
|
|
482
|
+
appId: string;
|
|
483
|
+
presetName: string;
|
|
484
|
+
} {
|
|
485
|
+
const env = this.env as unknown as Env;
|
|
486
|
+
const accountId = env.CF_ACCOUNT_ID?.trim();
|
|
487
|
+
const apiToken = env.CF_API_TOKEN?.trim();
|
|
488
|
+
const appId = env.CF_REALTIME_APP_ID?.trim();
|
|
489
|
+
const presetName = env.CF_REALTIME_PRESET_NAME?.trim() || 'group_call_participant';
|
|
490
|
+
|
|
491
|
+
if (!accountId || !apiToken || !appId) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
'Cloudflare Realtime is not configured. Set CF_ACCOUNT_ID, CF_API_TOKEN, and CF_REALTIME_APP_ID.',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { accountId, apiToken, appId, presetName };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private buildCloudflareRealtimeKitParticipantId(memberId: string, provided?: string): string {
|
|
501
|
+
const trimmed = typeof provided === 'string' ? provided.trim() : '';
|
|
502
|
+
if (trimmed) {
|
|
503
|
+
return trimmed;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return [
|
|
507
|
+
this.namespace ?? 'room',
|
|
508
|
+
this.roomId ?? 'unknown',
|
|
509
|
+
memberId,
|
|
510
|
+
Date.now().toString(36),
|
|
511
|
+
].join(':');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async ensureCloudflareRealtimeKitMeetingId(config: {
|
|
515
|
+
accountId: string;
|
|
516
|
+
apiToken: string;
|
|
517
|
+
appId: string;
|
|
518
|
+
}): Promise<string> {
|
|
519
|
+
if (this.cloudflareRealtimeKitMeetingId) {
|
|
520
|
+
return this.cloudflareRealtimeKitMeetingId;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (this.cloudflareRealtimeKitMeetingIdPromise) {
|
|
524
|
+
return this.cloudflareRealtimeKitMeetingIdPromise;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.cloudflareRealtimeKitMeetingIdPromise = (async () => {
|
|
528
|
+
const storedMeetingId = await this.ctx.storage.get<string>(CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY);
|
|
529
|
+
if (storedMeetingId) {
|
|
530
|
+
this.cloudflareRealtimeKitMeetingId = storedMeetingId;
|
|
531
|
+
return storedMeetingId;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const response = await fetch(
|
|
535
|
+
`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId)}`
|
|
536
|
+
+ `/realtime/kit/${encodeURIComponent(config.appId)}/meetings`,
|
|
537
|
+
{
|
|
538
|
+
method: 'POST',
|
|
539
|
+
headers: {
|
|
540
|
+
Authorization: `Bearer ${config.apiToken}`,
|
|
541
|
+
'Content-Type': 'application/json',
|
|
542
|
+
},
|
|
543
|
+
body: JSON.stringify({
|
|
544
|
+
title: `${this.namespace ?? 'room'}::${this.roomId ?? 'unknown'}`,
|
|
545
|
+
}),
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
const data = await this.parseCloudflareApiEnvelope<{ id: string }>(response);
|
|
549
|
+
this.cloudflareRealtimeKitMeetingId = data.id;
|
|
550
|
+
await this.ctx.storage.put(CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY, data.id);
|
|
551
|
+
return data.id;
|
|
552
|
+
})();
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
return await this.cloudflareRealtimeKitMeetingIdPromise;
|
|
556
|
+
} finally {
|
|
557
|
+
this.cloudflareRealtimeKitMeetingIdPromise = null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private async createCloudflareRealtimeKitParticipant(
|
|
562
|
+
config: {
|
|
563
|
+
accountId: string;
|
|
564
|
+
apiToken: string;
|
|
565
|
+
appId: string;
|
|
566
|
+
presetName: string;
|
|
567
|
+
},
|
|
568
|
+
meetingId: string,
|
|
569
|
+
payload: {
|
|
570
|
+
customParticipantId: string;
|
|
571
|
+
name?: string;
|
|
572
|
+
picture?: string;
|
|
573
|
+
},
|
|
574
|
+
): Promise<{ id: string; token: string; presetName?: string }> {
|
|
575
|
+
const response = await fetch(
|
|
576
|
+
`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId)}`
|
|
577
|
+
+ `/realtime/kit/${encodeURIComponent(config.appId)}`
|
|
578
|
+
+ `/meetings/${encodeURIComponent(meetingId)}/participants`,
|
|
579
|
+
{
|
|
580
|
+
method: 'POST',
|
|
581
|
+
headers: {
|
|
582
|
+
Authorization: `Bearer ${config.apiToken}`,
|
|
583
|
+
'Content-Type': 'application/json',
|
|
584
|
+
},
|
|
585
|
+
body: JSON.stringify({
|
|
586
|
+
custom_participant_id: payload.customParticipantId,
|
|
587
|
+
preset_name: config.presetName,
|
|
588
|
+
name: payload.name,
|
|
589
|
+
picture: payload.picture,
|
|
590
|
+
}),
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const data = await this.parseCloudflareApiEnvelope<{
|
|
595
|
+
id: string;
|
|
596
|
+
token: string;
|
|
597
|
+
preset_name?: string;
|
|
598
|
+
}>(response);
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
id: data.id,
|
|
602
|
+
token: data.token,
|
|
603
|
+
presetName: data.preset_name,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private async parseCloudflareApiEnvelope<T>(response: Response): Promise<T> {
|
|
608
|
+
const payload = (await response.json().catch(() => ({}))) as {
|
|
609
|
+
success?: boolean;
|
|
610
|
+
errors?: Array<{ message?: string }>;
|
|
611
|
+
messages?: Array<{ message?: string }>;
|
|
612
|
+
result?: T;
|
|
613
|
+
data?: T;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
if (!response.ok || payload.success === false) {
|
|
617
|
+
const message =
|
|
618
|
+
payload.errors?.find((entry) => typeof entry.message === 'string')?.message
|
|
619
|
+
?? payload.messages?.find((entry) => typeof entry.message === 'string')?.message
|
|
620
|
+
?? `Cloudflare API request failed (${response.status})`;
|
|
621
|
+
throw new Error(message);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return (payload.data ?? payload.result ?? {}) as T;
|
|
625
|
+
}
|
|
626
|
+
|
|
425
627
|
private async authenticateRealtimeRequest(
|
|
426
628
|
request: Request,
|
|
427
629
|
url: URL,
|
|
@@ -194,7 +194,7 @@ export async function updateUser(
|
|
|
194
194
|
'email', 'passwordHash', 'displayName', 'avatarUrl', 'emailVisibility',
|
|
195
195
|
'role', 'status', 'verified', 'isAnonymous', 'locale', 'metadata', 'appMetadata',
|
|
196
196
|
'customClaims', 'phone', 'phoneVerified', 'disabled', 'bannedUntil',
|
|
197
|
-
'lastSignInAt', 'updatedAt',
|
|
197
|
+
'lastSignInAt', 'lastSignedInAt', 'updatedAt',
|
|
198
198
|
]);
|
|
199
199
|
|
|
200
200
|
const sets: string[] = [];
|
package/src/lib/auth-d1.ts
CHANGED
|
@@ -125,6 +125,7 @@ CREATE TABLE IF NOT EXISTS _users (
|
|
|
125
125
|
disabled INTEGER DEFAULT 0,
|
|
126
126
|
status TEXT DEFAULT 'active',
|
|
127
127
|
locale TEXT DEFAULT 'en',
|
|
128
|
+
lastSignedInAt TEXT,
|
|
128
129
|
createdAt TEXT NOT NULL,
|
|
129
130
|
updatedAt TEXT NOT NULL
|
|
130
131
|
);
|
|
@@ -309,6 +310,7 @@ CREATE TABLE IF NOT EXISTS _users (
|
|
|
309
310
|
disabled INTEGER DEFAULT 0,
|
|
310
311
|
status TEXT DEFAULT 'active',
|
|
311
312
|
locale TEXT DEFAULT 'en',
|
|
313
|
+
lastSignedInAt TEXT,
|
|
312
314
|
createdAt TEXT NOT NULL,
|
|
313
315
|
updatedAt TEXT NOT NULL
|
|
314
316
|
);
|
|
@@ -403,6 +405,14 @@ export async function ensureAuthSchema(db: AuthDb): Promise<void> {
|
|
|
403
405
|
.filter((s) => s.length > 0);
|
|
404
406
|
|
|
405
407
|
await db.batch(statements.map((sql) => ({ sql })));
|
|
408
|
+
|
|
409
|
+
// Migrate existing _users tables: add lastSignedInAt if missing
|
|
410
|
+
try {
|
|
411
|
+
await db.run('ALTER TABLE _users ADD COLUMN lastSignedInAt TEXT', []);
|
|
412
|
+
} catch {
|
|
413
|
+
// Column already exists — safe to ignore
|
|
414
|
+
}
|
|
415
|
+
|
|
406
416
|
schemaInitialized = true;
|
|
407
417
|
}
|
|
408
418
|
|
package/src/lib/d1-handler.ts
CHANGED
|
@@ -399,8 +399,27 @@ async function handleList(
|
|
|
399
399
|
}
|
|
400
400
|
|
|
401
401
|
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
402
|
-
|
|
403
|
-
|
|
402
|
+
let query = buildListQuery(tableName, queryOpts, 'sqlite');
|
|
403
|
+
let result;
|
|
404
|
+
try {
|
|
405
|
+
result = await executeD1Query(resolved.db, query.sql, query.params);
|
|
406
|
+
} catch {
|
|
407
|
+
// FTS table may not exist — fall back to substring search
|
|
408
|
+
if (queryOpts.search) {
|
|
409
|
+
const searchFields = tableConfig.schema ? Object.keys(tableConfig.schema).filter(k => tableConfig.schema![k] !== false) : ['id'];
|
|
410
|
+
query = buildSubstringSearchQuery(tableName, queryOpts.search, {
|
|
411
|
+
pagination: queryOpts.pagination,
|
|
412
|
+
filters: queryOpts.filters,
|
|
413
|
+
orFilters: queryOpts.orFilters,
|
|
414
|
+
sort: queryOpts.sort,
|
|
415
|
+
fields: searchFields,
|
|
416
|
+
}, 'sqlite');
|
|
417
|
+
result = await executeD1Query(resolved.db, query.sql, query.params);
|
|
418
|
+
} else {
|
|
419
|
+
throw new Error('Query failed');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const { countSql, countParams } = query;
|
|
404
423
|
|
|
405
424
|
// Apply read rules per row + normalize booleans/JSON
|
|
406
425
|
let items = result.rows.map(r => normalizeRow(stripInternalFields(r), tableConfig));
|
|
@@ -435,7 +454,7 @@ async function handleList(
|
|
|
435
454
|
total = Number(countResult.rows[0]?.total ?? 0);
|
|
436
455
|
}
|
|
437
456
|
|
|
438
|
-
const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ??
|
|
457
|
+
const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 100;
|
|
439
458
|
const page = queryOpts.pagination?.page ?? 1;
|
|
440
459
|
// Always include cursor/hasMore like DO does — clients can start cursor pagination from any page
|
|
441
460
|
const hasMore = items.length === perPage;
|
|
@@ -497,7 +516,7 @@ async function handleSearch(
|
|
|
497
516
|
|
|
498
517
|
let items: Record<string, unknown>[];
|
|
499
518
|
let total = 0;
|
|
500
|
-
const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ??
|
|
519
|
+
const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 100;
|
|
501
520
|
const offset = queryOpts.pagination?.offset ?? ((queryOpts.pagination?.page ?? 1) - 1) * limit;
|
|
502
521
|
const searchQuery = buildSearchQuery(tableName, searchTerm, {
|
|
503
522
|
pagination: queryOpts.pagination,
|
|
@@ -2,17 +2,14 @@ import type { Context } from 'hono';
|
|
|
2
2
|
import type { HonoEnv } from './hono.js';
|
|
3
3
|
import type { Env } from '../types.js';
|
|
4
4
|
|
|
5
|
-
export function isTrustedInternalRequestUrl(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} catch {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
5
|
+
export function isTrustedInternalRequestUrl(_url: string): boolean {
|
|
6
|
+
// Requests are only trusted when the runtime marks them internal via context.
|
|
7
|
+
// URL hosts (including 'do' / 'internal') can be spoofed in self-hosted or proxy setups.
|
|
8
|
+
return false;
|
|
12
9
|
}
|
|
13
10
|
|
|
14
11
|
export function isTrustedInternalContext(c: Pick<Context<HonoEnv>, 'get' | 'req'>): boolean {
|
|
15
|
-
return c.get('isInternalRequest' as never) === true
|
|
12
|
+
return c.get('isInternalRequest' as never) === true;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
export function buildInternalHandlerContext(options: {
|
package/src/lib/openapi.ts
CHANGED
package/src/lib/pagination.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pagination parameter validation.
|
|
3
3
|
*
|
|
4
|
-
* Clamps limit to 1..
|
|
4
|
+
* Clamps limit to 1..1000 (default 100) and offset to ≥0 (default 0).
|
|
5
5
|
* Handles NaN, negative, and absurdly large values safely.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -9,8 +9,8 @@ export function parsePagination(
|
|
|
9
9
|
limitParam: string | undefined,
|
|
10
10
|
offsetParam: string | undefined,
|
|
11
11
|
): { limit: number; offset: number } {
|
|
12
|
-
const rawLimit = parseInt(limitParam || '
|
|
13
|
-
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit,
|
|
12
|
+
const rawLimit = parseInt(limitParam || '100', 10);
|
|
13
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 1000) : 100;
|
|
14
14
|
|
|
15
15
|
const rawOffset = parseInt(offsetParam || '0', 10);
|
|
16
16
|
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
|
|
@@ -365,7 +365,7 @@ async function handleList(
|
|
|
365
365
|
total = Number(countResult.rows[0]?.total ?? 0);
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ??
|
|
368
|
+
const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 100;
|
|
369
369
|
const page = queryOpts.pagination?.page ?? 1;
|
|
370
370
|
const hasMore = queryOpts.pagination?.after || queryOpts.pagination?.before
|
|
371
371
|
? items.length >= perPage
|
|
@@ -428,7 +428,7 @@ async function handleSearch(
|
|
|
428
428
|
const ftsFields = tableConfig.fts?.length
|
|
429
429
|
? tableConfig.fts
|
|
430
430
|
: getTextFields(tableConfig);
|
|
431
|
-
const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ??
|
|
431
|
+
const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 100;
|
|
432
432
|
const offset = queryOpts.pagination?.offset ?? ((queryOpts.pagination?.page ?? 1) - 1) * limit;
|
|
433
433
|
const searchQuery = buildSearchQuery(tableName, searchTerm, {
|
|
434
434
|
pagination: queryOpts.pagination,
|
package/src/lib/query-engine.ts
CHANGED
|
@@ -657,10 +657,10 @@ function buildLimitClause(
|
|
|
657
657
|
const _bt = bt ?? new BindTracker('sqlite');
|
|
658
658
|
|
|
659
659
|
if (!pagination) {
|
|
660
|
-
return { limitClause: `LIMIT ${_bt.next()}`, limitParams: [
|
|
660
|
+
return { limitClause: `LIMIT ${_bt.next()}`, limitParams: [100] }; // Default limit
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
-
const limit = pagination.limit ?? pagination.perPage ??
|
|
663
|
+
const limit = Math.min(pagination.limit ?? pagination.perPage ?? 100, 1000);
|
|
664
664
|
|
|
665
665
|
// Cursor-based: no offset
|
|
666
666
|
if (pagination.after || pagination.before) {
|
|
@@ -50,17 +50,17 @@ export const RATE_LIMIT_DEFAULTS: Record<string, { requests: number; windowSec:
|
|
|
50
50
|
events: { requests: 100, windowSec: 60 },
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
// Dev mode defaults:
|
|
54
|
-
// hot-reload page refreshes,
|
|
53
|
+
// Dev mode defaults: significantly higher to accommodate React strict mode double-rendering,
|
|
54
|
+
// hot-reload page refreshes, onSnapshot polling, and multi-client testing during development.
|
|
55
55
|
export const RATE_LIMIT_DEV_DEFAULTS: Record<string, { requests: number; windowSec: number }> = {
|
|
56
56
|
global: { requests: 10_000_000, windowSec: 60 },
|
|
57
|
-
db: { requests:
|
|
58
|
-
storage: { requests:
|
|
59
|
-
functions: { requests:
|
|
60
|
-
auth: { requests:
|
|
57
|
+
db: { requests: 5000, windowSec: 60 },
|
|
58
|
+
storage: { requests: 1000, windowSec: 60 },
|
|
59
|
+
functions: { requests: 1000, windowSec: 60 },
|
|
60
|
+
auth: { requests: 500, windowSec: 60 },
|
|
61
61
|
authSignin: { requests: 100, windowSec: 60 },
|
|
62
62
|
authSignup: { requests: 100, windowSec: 60 },
|
|
63
|
-
events: { requests:
|
|
63
|
+
events: { requests: 5000, windowSec: 60 },
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
// ─── Window parser ───
|
|
@@ -256,7 +256,7 @@ export const rateLimitMiddleware: MiddlewareHandler<HonoEnv> = async (c, next) =
|
|
|
256
256
|
if (!counter.check(counterKey, requests, windowSec)) {
|
|
257
257
|
c.header('Retry-After', String(counter.getRetryAfter(counterKey)));
|
|
258
258
|
return c.json(
|
|
259
|
-
{ code: 429, message: 'Too many requests. Please try again later.' },
|
|
259
|
+
{ code: 429, message: 'Too many requests. Please try again later.', group },
|
|
260
260
|
429,
|
|
261
261
|
);
|
|
262
262
|
}
|
|
@@ -268,7 +268,7 @@ export const rateLimitMiddleware: MiddlewareHandler<HonoEnv> = async (c, next) =
|
|
|
268
268
|
if (!success) {
|
|
269
269
|
c.header('Retry-After', '60');
|
|
270
270
|
return c.json(
|
|
271
|
-
{ code: 429, message: 'Too many requests. Please try again later.' },
|
|
271
|
+
{ code: 429, message: 'Too many requests. Please try again later.', group },
|
|
272
272
|
429,
|
|
273
273
|
);
|
|
274
274
|
}
|
|
@@ -282,7 +282,7 @@ export const rateLimitMiddleware: MiddlewareHandler<HonoEnv> = async (c, next) =
|
|
|
282
282
|
if (!counter.check(globalKey, globalLimit.requests, globalLimit.windowSec)) {
|
|
283
283
|
c.header('Retry-After', String(counter.getRetryAfter(globalKey)));
|
|
284
284
|
return c.json(
|
|
285
|
-
{ code: 429, message: 'Too many requests. Please try again later.' },
|
|
285
|
+
{ code: 429, message: 'Too many requests. Please try again later.', group: 'global' },
|
|
286
286
|
429,
|
|
287
287
|
);
|
|
288
288
|
}
|
|
@@ -294,7 +294,7 @@ export const rateLimitMiddleware: MiddlewareHandler<HonoEnv> = async (c, next) =
|
|
|
294
294
|
if (!success) {
|
|
295
295
|
c.header('Retry-After', '60');
|
|
296
296
|
return c.json(
|
|
297
|
-
{ code: 429, message: 'Too many requests. Please try again later.' },
|
|
297
|
+
{ code: 429, message: 'Too many requests. Please try again later.', group: 'global' },
|
|
298
298
|
429,
|
|
299
299
|
);
|
|
300
300
|
}
|
package/src/routes/admin.ts
CHANGED
|
@@ -119,6 +119,15 @@ function quoteSqlIdentifier(identifier: string): string {
|
|
|
119
119
|
return `"${identifier}"`;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function isPublicAdminSetupAllowed(env: Env): boolean {
|
|
123
|
+
try {
|
|
124
|
+
const config = parseConfig(env);
|
|
125
|
+
return config.release !== true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
interface MonitoringStats {
|
|
123
132
|
subsystem?: string;
|
|
124
133
|
activeConnections: number;
|
|
@@ -231,8 +240,8 @@ function getLogStatusCode(log: Record<string, unknown>): number {
|
|
|
231
240
|
function matchesLogLevel(status: number, level: string): boolean {
|
|
232
241
|
const normalized = level.toLowerCase();
|
|
233
242
|
if (normalized === 'error') return status >= 500;
|
|
234
|
-
if (normalized === 'warn') return status >= 300 && status < 500;
|
|
235
|
-
if (normalized === 'info') return status >= 200 && status < 300;
|
|
243
|
+
if (normalized === 'warn') return (status >= 300 && status < 500 && status !== 304);
|
|
244
|
+
if (normalized === 'info') return (status >= 200 && status < 300) || status === 304;
|
|
236
245
|
return true;
|
|
237
246
|
}
|
|
238
247
|
|
|
@@ -361,7 +370,16 @@ const adminSetupStatus = createRoute({
|
|
|
361
370
|
adminRoute.openapi(adminSetupStatus, async (c) => {
|
|
362
371
|
await ensureAuthSchema(getAuthDb(c));
|
|
363
372
|
const exists = await adminExists(getAuthDb(c));
|
|
364
|
-
|
|
373
|
+
const needsSetup = !exists;
|
|
374
|
+
const publicSetupAllowed = needsSetup ? isPublicAdminSetupAllowed(c.env) : false;
|
|
375
|
+
return c.json({
|
|
376
|
+
needsSetup,
|
|
377
|
+
publicSetupAllowed,
|
|
378
|
+
setupMethod: needsSetup ? (publicSetupAllowed ? 'browser' : 'cli') : 'login',
|
|
379
|
+
message: needsSetup && !publicSetupAllowed
|
|
380
|
+
? 'Public admin setup is disabled for this deployment. Run `npx edgebase admin bootstrap` with a Service Key, or use the deploy/docker bootstrap flow instead.'
|
|
381
|
+
: undefined,
|
|
382
|
+
});
|
|
365
383
|
});
|
|
366
384
|
|
|
367
385
|
// POST /admin/api/setup — create the first admin account
|
|
@@ -387,6 +405,7 @@ const adminSetup = createRoute({
|
|
|
387
405
|
responses: {
|
|
388
406
|
201: { description: 'Admin created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
389
407
|
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
408
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
390
409
|
},
|
|
391
410
|
});
|
|
392
411
|
|
|
@@ -394,6 +413,14 @@ adminRoute.openapi(adminSetup, async (c) => {
|
|
|
394
413
|
await ensureAuthSchema(getAuthDb(c));
|
|
395
414
|
const exists = await adminExists(getAuthDb(c));
|
|
396
415
|
if (exists) throw new EdgeBaseError(400, 'Admin account already exists. Use login instead.', undefined, 'already-exists');
|
|
416
|
+
if (!isPublicAdminSetupAllowed(c.env)) {
|
|
417
|
+
throw new EdgeBaseError(
|
|
418
|
+
403,
|
|
419
|
+
'Public admin setup is disabled for this deployment. Run `npx edgebase admin bootstrap` with a Service Key, or use the deploy/docker bootstrap flow instead.',
|
|
420
|
+
undefined,
|
|
421
|
+
'forbidden',
|
|
422
|
+
);
|
|
423
|
+
}
|
|
397
424
|
|
|
398
425
|
const body = await c.req.json<{ email: string; password: string }>();
|
|
399
426
|
if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
|