@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.
Files changed (100) hide show
  1. package/admin-build/_app/immutable/assets/19.4Si2ZFC_.css +1 -0
  2. package/admin-build/_app/immutable/assets/{3.Dg81Pgmd.css → 3.BtHYobTg.css} +1 -1
  3. package/admin-build/_app/immutable/assets/SqlEditor.Bbp1RIk0.css +1 -0
  4. package/admin-build/_app/immutable/assets/TableSqlTab.yeNZfhgG.css +1 -0
  5. package/admin-build/_app/immutable/chunks/B0QyxC2M.js +128 -0
  6. package/admin-build/_app/immutable/chunks/{Bsp3uE8m.js → BCKr7yKd.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{CWHVkYdi.js → BFs_qStz.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{CKIubXVC.js → BTJcQFEp.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/BY07qVPA.js +1 -0
  10. package/admin-build/_app/immutable/chunks/{Cn5ZQY9O.js → BcIUK2sk.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{CKFIU4S8.js → BsFiK_FJ.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{COYZ6F_d.js → CSGrwS7E.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{DLc6L4xD.js → CqUxCvs_.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{DLTo2HQr.js → D-x55wdW.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/D755Tqat.js +1 -0
  16. package/admin-build/_app/immutable/chunks/{Jxx0jGlP.js → DjOEv9M9.js} +1 -1
  17. package/admin-build/_app/immutable/chunks/{DLRcaFHo.js → DnLqc9L1.js} +1 -1
  18. package/admin-build/_app/immutable/chunks/{BnlxEqP4.js → Dqk2TGNU.js} +1 -1
  19. package/admin-build/_app/immutable/chunks/{7FYfV8UU.js → k0CIJkw4.js} +1 -1
  20. package/admin-build/_app/immutable/chunks/lSpxLU5p.js +2 -0
  21. package/admin-build/_app/immutable/chunks/m9QZTyVV.js +1 -0
  22. package/admin-build/_app/immutable/entry/{app.C4kLStKR.js → app.BTsq3_xq.js} +2 -2
  23. package/admin-build/_app/immutable/entry/start.zXCirpgY.js +1 -0
  24. package/admin-build/_app/immutable/nodes/0.BZ00WDYH.js +1 -0
  25. package/admin-build/_app/immutable/nodes/{1.s1kW8gyv.js → 1.RzSJ3yyr.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{10.Cr9ml-GD.js → 10.D-rsiquF.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{11.DXTOyMEn.js → 11.l7-bgtFD.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{12.DzhitECA.js → 12.Dkq0H7B5.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{13.U_1VNA5x.js → 13.DtK_4oRz.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{14.x0SzmV0P.js → 14.BKo7-AMx.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{15.BJI1Z1hk.js → 15.CQAj_6lq.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/{16.CUvRFqLW.js → 16.XVIG-Ffr.js} +1 -1
  33. package/admin-build/_app/immutable/nodes/{17.sDFD-Vbm.js → 17.g6raZLCM.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{18.DdoTnAXc.js → 18.IQz6a3T6.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/19.CAAZ8i8h.js +2 -0
  36. package/admin-build/_app/immutable/nodes/{20.fxudyPKp.js → 20.BPcX3KPj.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/21.DoPabrY_.js +1 -0
  38. package/admin-build/_app/immutable/nodes/{22.CY6ICoyn.js → 22.Br5AG_5Z.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{23.DmEaHsZw.js → 23.KjbrdXoE.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{24.Cqerdln2.js → 24.C3n2-hgw.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/25.SFDSBzHd.js +2 -0
  42. package/admin-build/_app/immutable/nodes/26.D95vui6E.js +1 -0
  43. package/admin-build/_app/immutable/nodes/{27.DAeKhnG9.js → 27.FgLgdjwB.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{28.D_lJZpNR.js → 28.B9sYYm1F.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{29.Dx7iQCpT.js → 29.DyqZ_wbN.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/3.Bzo2yVIO.js +2 -0
  47. package/admin-build/_app/immutable/nodes/{30.DcO-bjQZ.js → 30.c1CiNwiS.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{31.DesWAzIF.js → 31.CXty66Vh.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{4.B2rm4_4a.js → 4.BgQaXZ27.js} +1 -1
  50. package/admin-build/_app/immutable/nodes/{5.4Os1aYeW.js → 5.BuJrHvxH.js} +1 -1
  51. package/admin-build/_app/immutable/nodes/{6.BzR--5Dj.js → 6.CkBBC94k.js} +1 -1
  52. package/admin-build/_app/immutable/nodes/{7.QDC-47H1.js → 7.D2YBvNFM.js} +1 -1
  53. package/admin-build/_app/immutable/nodes/{8.DDPWEc96.js → 8.D8qQWo_z.js} +1 -1
  54. package/admin-build/_app/immutable/nodes/{9.fyfVIJPa.js → 9.BLDLX5hV.js} +1 -1
  55. package/admin-build/_app/version.json +1 -1
  56. package/admin-build/index.html +7 -7
  57. package/openapi.json +6710 -5866
  58. package/package.json +2 -2
  59. package/src/__tests__/functions-route.test.ts +153 -0
  60. package/src/__tests__/internal-request.test.ts +5 -5
  61. package/src/__tests__/meta-export-coverage.test.ts +3 -2
  62. package/src/__tests__/meta-route-registration.test.ts +3 -2
  63. package/src/__tests__/openapi-coverage.test.ts +5 -1
  64. package/src/__tests__/pagination.test.ts +12 -8
  65. package/src/__tests__/postgres-dialect.test.ts +2 -2
  66. package/src/__tests__/query.test.ts +7 -7
  67. package/src/__tests__/rate-limit.test.ts +0 -1
  68. package/src/__tests__/room-handler-context.test.ts +31 -0
  69. package/src/__tests__/room-runtime-routing.test.ts +48 -0
  70. package/src/__tests__/runtime-surface-accounting.test.ts +5 -4
  71. package/src/__tests__/smoke-skip-report.test.ts +3 -2
  72. package/src/durable-objects/database-do.ts +9 -4
  73. package/src/durable-objects/database-live-do.ts +22 -10
  74. package/src/durable-objects/logs-do.ts +2 -2
  75. package/src/durable-objects/rooms-do.ts +202 -0
  76. package/src/lib/auth-d1-service.ts +1 -1
  77. package/src/lib/auth-d1.ts +10 -0
  78. package/src/lib/d1-handler.ts +23 -4
  79. package/src/lib/internal-request.ts +5 -8
  80. package/src/lib/openapi.ts +1 -0
  81. package/src/lib/pagination.ts +3 -3
  82. package/src/lib/postgres-handler.ts +2 -2
  83. package/src/lib/query-engine.ts +2 -2
  84. package/src/middleware/rate-limit.ts +11 -11
  85. package/src/routes/admin.ts +30 -3
  86. package/src/routes/auth.ts +74 -33
  87. package/src/routes/room.ts +42 -0
  88. package/src/types.ts +6 -0
  89. package/admin-build/_app/immutable/assets/TableSqlTab.BHquaMBM.css +0 -1
  90. package/admin-build/_app/immutable/chunks/BPXFNSAT.js +0 -128
  91. package/admin-build/_app/immutable/chunks/DKjA1S1a.js +0 -1
  92. package/admin-build/_app/immutable/chunks/fBE8lw-R.js +0 -1
  93. package/admin-build/_app/immutable/chunks/gE9fQ_Ff.js +0 -2
  94. package/admin-build/_app/immutable/entry/start.CAAH6ztW.js +0 -1
  95. package/admin-build/_app/immutable/nodes/0.BywaJfpH.js +0 -1
  96. package/admin-build/_app/immutable/nodes/19.CUvRFqLW.js +0 -1
  97. package/admin-build/_app/immutable/nodes/21.N2QN0lbw.js +0 -1
  98. package/admin-build/_app/immutable/nodes/25.x3hL7Y-O.js +0 -2
  99. package/admin-build/_app/immutable/nodes/26.CEzfjTSO.js +0 -1
  100. 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 (change.data && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0)) {
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 = evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters);
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 (change.data && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0)) {
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 = evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters);
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 (eventData && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) && msgChannel) {
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 = evaluateDatabaseLiveFilters(eventData, filters, orFilters);
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')), 50),
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')), 50),
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[] = [];
@@ -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
 
@@ -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
- const { sql, params, countSql, countParams } = buildListQuery(tableName, queryOpts, 'sqlite');
403
- const result = await executeD1Query(resolved.db, sql, params);
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 ?? 20;
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 ?? 20;
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(url: string): boolean {
6
- try {
7
- const host = new URL(url).host;
8
- return host === 'internal' || host === 'do';
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 || isTrustedInternalRequestUrl(c.req.url);
12
+ return c.get('isInternalRequest' as never) === true;
16
13
  }
17
14
 
18
15
  export function buildInternalHandlerContext(options: {
@@ -49,6 +49,7 @@ const USER_BEARER_PATHS = new Set([
49
49
 
50
50
  const USER_BEARER_PREFIXES = [
51
51
  '/api/room/media/realtime/',
52
+ '/api/room/media/cloudflare_realtimekit/',
52
53
  ];
53
54
 
54
55
  const SERVICE_KEY_ONLY_PATHS = new Set([
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pagination parameter validation.
3
3
  *
4
- * Clamps limit to 1..100 (default 20) and offset to ≥0 (default 0).
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 || '20', 10);
13
- const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 100) : 20;
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 ?? 20;
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 ?? 20;
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,
@@ -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: [20] }; // Default limit
660
+ return { limitClause: `LIMIT ${_bt.next()}`, limitParams: [100] }; // Default limit
661
661
  }
662
662
 
663
- const limit = pagination.limit ?? pagination.perPage ?? 20;
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: 10x higher to accommodate React strict mode double-rendering,
54
- // hot-reload page refreshes, and onSnapshot polling during development.
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: 1000, windowSec: 60 },
58
- storage: { requests: 500, windowSec: 60 },
59
- functions: { requests: 500, windowSec: 60 },
60
- auth: { requests: 300, windowSec: 60 },
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: 1000, windowSec: 60 },
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
  }
@@ -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
- return c.json({ needsSetup: !exists });
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');