@edge-base/server 0.1.5 → 0.2.0

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 (77) hide show
  1. package/admin-build/_app/immutable/chunks/{7FYfV8UU.js → 2nyN5wuZ.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{BnlxEqP4.js → B-WlnirM.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DLc6L4xD.js → B14gOIqE.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{DLTo2HQr.js → BKLsgaNT.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{CWHVkYdi.js → BSfSfeDG.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{COYZ6F_d.js → CN6aakgF.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/CfPHB4r5.js +1 -0
  8. package/admin-build/_app/immutable/chunks/{gE9fQ_Ff.js → CkdaVlhQ.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{CKIubXVC.js → D43CH5ty.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{BPXFNSAT.js → D8Nrx_IG.js} +3 -3
  11. package/admin-build/_app/immutable/chunks/{DLRcaFHo.js → DP9kmlCd.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{CKFIU4S8.js → DgxOZ3uv.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{Bsp3uE8m.js → DpuSetmN.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{Jxx0jGlP.js → cqSkc6KP.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{Cn5ZQY9O.js → mD4EETH_.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/uboHVq-x.js +1 -0
  17. package/admin-build/_app/immutable/entry/{app.C4kLStKR.js → app.Dc071f6C.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Bhlxoqtt.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.BywaJfpH.js → 0.CCfcYVV2.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.s1kW8gyv.js → 1.rMaczUKT.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.Cr9ml-GD.js → 10.DIOlO4hv.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.DXTOyMEn.js → 11.WxD9E0Eq.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.DzhitECA.js → 12.CNcefK3l.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.U_1VNA5x.js → 13.aAWsqDdR.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.x0SzmV0P.js → 14.C9hdr3EN.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.BJI1Z1hk.js → 15.43r5uVx5.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.CUvRFqLW.js → 16.D519948J.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.sDFD-Vbm.js → 17.ks4I4yoH.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DdoTnAXc.js → 18.ZuNm22dY.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CUvRFqLW.js → 19.D519948J.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.fxudyPKp.js → 20.C9ASlwCn.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.BhSD2EfX.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.CY6ICoyn.js → 22.6k8cg0Pr.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DmEaHsZw.js → 23.B9hcFTU-.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.Cqerdln2.js → 24.OsQM9QtS.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/25.ClwkdaPp.js +2 -0
  37. package/admin-build/_app/immutable/nodes/{26.CEzfjTSO.js → 26._-65WG0q.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.DAeKhnG9.js → 27.J1QASB3b.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.D_lJZpNR.js → 28.BKP1tVcZ.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.Dx7iQCpT.js → 29.mqIe62On.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.CKC_yDnF.js → 3.WkDZWDQC.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.DcO-bjQZ.js → 30.BRk-4B3j.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.DesWAzIF.js → 31.BBqGNVXN.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.B2rm4_4a.js → 4.Bi91lv2V.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.4Os1aYeW.js → 5.BumjsbNK.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BzR--5Dj.js → 6.CMTP_7xN.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.QDC-47H1.js → 7.4T4wo7Kg.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.DDPWEc96.js → 8.MUZQPNsN.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.fyfVIJPa.js → 9.3SV00WXe.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +6710 -5866
  53. package/package.json +2 -2
  54. package/src/__tests__/functions-route.test.ts +153 -0
  55. package/src/__tests__/internal-request.test.ts +5 -5
  56. package/src/__tests__/meta-export-coverage.test.ts +3 -2
  57. package/src/__tests__/meta-route-registration.test.ts +3 -2
  58. package/src/__tests__/openapi-coverage.test.ts +5 -1
  59. package/src/__tests__/rate-limit.test.ts +0 -1
  60. package/src/__tests__/room-handler-context.test.ts +31 -0
  61. package/src/__tests__/room-runtime-routing.test.ts +48 -0
  62. package/src/__tests__/runtime-surface-accounting.test.ts +5 -4
  63. package/src/__tests__/smoke-skip-report.test.ts +3 -2
  64. package/src/durable-objects/database-do.ts +6 -1
  65. package/src/durable-objects/database-live-do.ts +22 -10
  66. package/src/durable-objects/rooms-do.ts +202 -0
  67. package/src/lib/internal-request.ts +5 -8
  68. package/src/lib/openapi.ts +1 -0
  69. package/src/routes/admin.ts +28 -1
  70. package/src/routes/auth.ts +67 -33
  71. package/src/routes/room.ts +42 -0
  72. package/src/types.ts +6 -0
  73. package/admin-build/_app/immutable/chunks/DKjA1S1a.js +0 -1
  74. package/admin-build/_app/immutable/chunks/fBE8lw-R.js +0 -1
  75. package/admin-build/_app/immutable/entry/start.CAAH6ztW.js +0 -1
  76. package/admin-build/_app/immutable/nodes/21.N2QN0lbw.js +0 -1
  77. package/admin-build/_app/immutable/nodes/25.x3hL7Y-O.js +0 -2
@@ -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,
@@ -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([
@@ -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;
@@ -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');
@@ -29,7 +29,7 @@ import {
29
29
  } from '../lib/auth-redirect.js';
30
30
  import {
31
31
  signAccessToken, signRefreshToken, verifyRefreshTokenWithFallback,
32
- parseDuration, decodeTokenUnsafe, TokenExpiredError,
32
+ parseDuration, TokenExpiredError,
33
33
  } from '../lib/jwt.js';
34
34
  import { generateId } from '../lib/uuid.js';
35
35
  import { captchaMiddleware } from '../middleware/captcha-verify.js';
@@ -108,6 +108,15 @@ authRoute.onError((err, c) => {
108
108
 
109
109
  // ─── Helpers ───
110
110
 
111
+ function isReleaseMode(env: Env): boolean {
112
+ return parseConfig(env)?.release ?? false;
113
+ }
114
+
115
+ function shouldExposeAuthTestSecrets(env: Env): boolean {
116
+ return env.EDGEBASE_TEST === '1'
117
+ || env.EDGEBASE_TEST === 'true';
118
+ }
119
+
111
120
  function requireAuth(auth: AuthContext | null): string {
112
121
  if (!auth) {
113
122
  throw new EdgeBaseError(401, 'Authentication required.', undefined, 'unauthenticated');
@@ -1288,6 +1297,7 @@ authRoute.openapi(signinMagicLink, async (c) => {
1288
1297
  const record = await lookupEmail(getAuthDb(c), body.email);
1289
1298
 
1290
1299
  const db = getAuthDb(c);
1300
+ const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
1291
1301
  let debugToken: string | undefined;
1292
1302
  let debugActionUrl: string | undefined;
1293
1303
 
@@ -1327,9 +1337,12 @@ authRoute.openapi(signinMagicLink, async (c) => {
1327
1337
  type: 'magic-link',
1328
1338
  state: redirect.state,
1329
1339
  });
1340
+ if (exposeTestSecrets) {
1341
+ debugToken = token;
1342
+ debugActionUrl = magicLinkUrl;
1343
+ }
1330
1344
  if (!provider) {
1331
- const release = config?.release ?? false;
1332
- if (!release) {
1345
+ if (!isReleaseMode(c.env)) {
1333
1346
  console.warn('[MagicLink] Email provider not configured. Token:', token);
1334
1347
  debugToken = token;
1335
1348
  debugActionUrl = magicLinkUrl;
@@ -1417,6 +1430,10 @@ authRoute.openapi(signinMagicLink, async (c) => {
1417
1430
  type: 'magic-link',
1418
1431
  state: redirect.state,
1419
1432
  });
1433
+ if (exposeTestSecrets) {
1434
+ debugToken = token;
1435
+ debugActionUrl = magicLinkUrl;
1436
+ }
1420
1437
  if (provider) {
1421
1438
  const locale = resolveEmailLocale(c.env, reqLocale);
1422
1439
  const html = renderMagicLink({
@@ -1431,8 +1448,7 @@ authRoute.openapi(signinMagicLink, async (c) => {
1431
1448
  resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
1432
1449
  ).catch(() => {});
1433
1450
  } else {
1434
- const release = config?.release ?? false;
1435
- if (!release) {
1451
+ if (!isReleaseMode(c.env)) {
1436
1452
  debugToken = token;
1437
1453
  debugActionUrl = magicLinkUrl;
1438
1454
  }
@@ -1591,6 +1607,7 @@ authRoute.openapi(signinPhone, async (c) => {
1591
1607
  // Look up phone in D1
1592
1608
  const record = await lookupPhone(getAuthDb(c), phone);
1593
1609
 
1610
+ const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
1594
1611
  let devCode: string | undefined;
1595
1612
 
1596
1613
  const db = getAuthDb(c);
@@ -1602,6 +1619,7 @@ authRoute.openapi(signinPhone, async (c) => {
1602
1619
  if (!user) return c.json({ ok: true });
1603
1620
 
1604
1621
  const code = generateOTP();
1622
+ if (exposeTestSecrets) devCode = code;
1605
1623
 
1606
1624
  // Store OTP in KV with 5 min TTL
1607
1625
  await c.env.KV.put(
@@ -1623,8 +1641,7 @@ authRoute.openapi(signinPhone, async (c) => {
1623
1641
  `Your ${appName} verification code is: ${code}. Valid for 5 minutes.`,
1624
1642
  );
1625
1643
  } else {
1626
- const release = parseConfig(c.env)?.release ?? false;
1627
- if (!release) {
1644
+ if (!isReleaseMode(c.env)) {
1628
1645
  console.warn('[Phone] SMS provider not configured. OTP:', code);
1629
1646
  devCode = code;
1630
1647
  }
@@ -1654,6 +1671,7 @@ authRoute.openapi(signinPhone, async (c) => {
1654
1671
  await authService.updateUser(db, userId, { phone, phoneVerified: false });
1655
1672
 
1656
1673
  const code = generateOTP();
1674
+ if (exposeTestSecrets) devCode = code;
1657
1675
 
1658
1676
  // Store OTP in KV
1659
1677
  await c.env.KV.put(
@@ -1675,8 +1693,7 @@ authRoute.openapi(signinPhone, async (c) => {
1675
1693
  `Your ${appName} verification code is: ${code}. Valid for 5 minutes.`,
1676
1694
  );
1677
1695
  } else {
1678
- const release = parseConfig(c.env)?.release ?? false;
1679
- if (!release) {
1696
+ if (!isReleaseMode(c.env)) {
1680
1697
  console.warn('[Phone] SMS provider not configured. OTP:', code);
1681
1698
  devCode = code;
1682
1699
  }
@@ -1690,8 +1707,7 @@ authRoute.openapi(signinPhone, async (c) => {
1690
1707
  }
1691
1708
 
1692
1709
  // Return OTP code only in dev mode (SMS provider not configured) for testing
1693
- const release = parseConfig(c.env)?.release ?? false;
1694
- return c.json(devCode && !release ? { ok: true, code: devCode } : { ok: true });
1710
+ return c.json(devCode ? { ok: true, code: devCode } : { ok: true });
1695
1711
  });
1696
1712
 
1697
1713
  // POST /verify-phone — verify OTP → create session
@@ -1871,6 +1887,7 @@ authRoute.openapi(linkPhone, async (c) => {
1871
1887
  }
1872
1888
 
1873
1889
  const code = generateOTP();
1890
+ const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
1874
1891
 
1875
1892
  // Store link OTP in KV (separate key pattern)
1876
1893
  await c.env.KV.put(
@@ -1891,14 +1908,13 @@ authRoute.openapi(linkPhone, async (c) => {
1891
1908
  phone,
1892
1909
  `Your ${appName} phone linking code is: ${code}. Valid for 5 minutes.`,
1893
1910
  );
1894
- return c.json({ ok: true });
1911
+ return c.json(exposeTestSecrets ? { ok: true, code } : { ok: true });
1895
1912
  } else {
1896
- const release = parseConfig(c.env)?.release ?? false;
1897
- if (!release) {
1913
+ if (!isReleaseMode(c.env)) {
1898
1914
  console.warn('[Phone] SMS provider not configured. Link OTP:', code);
1899
1915
  return c.json({ ok: true, code });
1900
1916
  }
1901
- return c.json({ ok: true });
1917
+ return c.json(exposeTestSecrets ? { ok: true, code } : { ok: true });
1902
1918
  }
1903
1919
  });
1904
1920
 
@@ -2048,6 +2064,7 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2048
2064
  // Look up email in D1
2049
2065
  const record = await lookupEmail(getAuthDb(c), email);
2050
2066
 
2067
+ const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
2051
2068
  let devCode: string | undefined;
2052
2069
 
2053
2070
  const db = getAuthDb(c);
@@ -2059,6 +2076,7 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2059
2076
  if (!user) return c.json({ ok: true });
2060
2077
 
2061
2078
  const code = generateOTP();
2079
+ if (exposeTestSecrets) devCode = code;
2062
2080
 
2063
2081
  // Store OTP in KV with 5 min TTL
2064
2082
  await c.env.KV.put(
@@ -2079,8 +2097,7 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2079
2097
  resolveSubject(c.env, 'emailOtp', defaultSubject, locale), html, locale,
2080
2098
  );
2081
2099
  } else {
2082
- const release = parseConfig(c.env)?.release ?? false;
2083
- if (!release) {
2100
+ if (!isReleaseMode(c.env)) {
2084
2101
  console.warn('[EmailOTP] Email provider not configured. OTP:', code);
2085
2102
  devCode = code;
2086
2103
  }
@@ -2117,6 +2134,7 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2117
2134
  });
2118
2135
 
2119
2136
  const code = generateOTP();
2137
+ if (exposeTestSecrets) devCode = code;
2120
2138
 
2121
2139
  // Store OTP in KV
2122
2140
  await c.env.KV.put(
@@ -2137,8 +2155,7 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2137
2155
  resolveSubject(c.env, 'emailOtp', defaultSubject, locale), html, locale,
2138
2156
  );
2139
2157
  } else {
2140
- const release = parseConfig(c.env)?.release ?? false;
2141
- if (!release) {
2158
+ if (!isReleaseMode(c.env)) {
2142
2159
  console.warn('[EmailOTP] Email provider not configured. OTP:', code);
2143
2160
  devCode = code;
2144
2161
  }
@@ -2152,8 +2169,7 @@ authRoute.openapi(signinEmailOtp, async (c) => {
2152
2169
  }
2153
2170
 
2154
2171
  // Return OTP code only in dev mode (email provider not configured) for testing
2155
- const release = parseConfig(c.env)?.release ?? false;
2156
- return c.json(devCode && !release ? { ok: true, code: devCode } : { ok: true });
2172
+ return c.json(devCode ? { ok: true, code: devCode } : { ok: true });
2157
2173
  });
2158
2174
 
2159
2175
  // POST /verify-email-otp — verify OTP → create session
@@ -2754,11 +2770,27 @@ authRoute.openapi(signout, async (c) => {
2754
2770
  refreshToken: body.refreshToken,
2755
2771
  });
2756
2772
 
2757
- const payload = decodeTokenUnsafe(body.refreshToken);
2758
- if (!payload?.sub) throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
2759
-
2760
2773
  const db = getAuthDb(c);
2761
- const userId = payload.sub as string;
2774
+ let tokenPayload;
2775
+ try {
2776
+ tokenPayload = await verifyRefreshTokenWithFallback(
2777
+ body.refreshToken,
2778
+ getUserSecret(c.env),
2779
+ c.env.JWT_USER_SECRET_OLD,
2780
+ c.env.JWT_USER_SECRET_OLD_AT,
2781
+ );
2782
+ } catch (err) {
2783
+ if (err instanceof TokenExpiredError) {
2784
+ throw new EdgeBaseError(401, 'Refresh token expired.', undefined, 'refresh-token-expired');
2785
+ }
2786
+ throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
2787
+ }
2788
+
2789
+ const userId = tokenPayload.sub;
2790
+ const sessionResult = await authService.getSessionByRefreshToken(db, body.refreshToken, userId);
2791
+ if (!sessionResult) {
2792
+ throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
2793
+ }
2762
2794
 
2763
2795
  // beforeSignOut hook — blocking
2764
2796
  await executeAuthHook(c.env, c.executionCtx, 'beforeSignOut', { userId }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
@@ -2991,8 +3023,8 @@ authRoute.openapi(changeEmail, async (c) => {
2991
3023
  }
2992
3024
  }
2993
3025
 
2994
- const release = parseConfig(c.env)?.release ?? false;
2995
- if (!release) {
3026
+ const exposeTestSecrets = shouldExposeAuthTestSecrets(c.env);
3027
+ if (exposeTestSecrets || !isReleaseMode(c.env)) {
2996
3028
  const emailCfg = getEmailConfig(c.env);
2997
3029
  const fallbackVerifyUrl = emailCfg?.emailChangeUrl
2998
3030
  ? emailCfg.emailChangeUrl.replace('{token}', token)
@@ -3970,8 +4002,7 @@ authRoute.openapi(requestEmailVerification, async (c) => {
3970
4002
 
3971
4003
  const provider = createEmailProvider(getEmailConfig(c.env), c.env);
3972
4004
  if (!provider) {
3973
- const release = parseConfig(c.env)?.release ?? false;
3974
- if (!release) {
4005
+ if (shouldExposeAuthTestSecrets(c.env) || !isReleaseMode(c.env)) {
3975
4006
  console.warn('[VerifyEmail] Email provider not configured. Verification email not sent. Token:', token);
3976
4007
  return c.json({ ok: true, message: 'Email provider not configured.', token, actionUrl: verifyUrl });
3977
4008
  }
@@ -3992,7 +4023,9 @@ authRoute.openapi(requestEmailVerification, async (c) => {
3992
4023
  resolveSubject(c.env, 'verification', defaultSubject, locale), html, locale,
3993
4024
  );
3994
4025
 
3995
- return c.json({ ok: result.success, messageId: result.messageId });
4026
+ return c.json(shouldExposeAuthTestSecrets(c.env)
4027
+ ? { ok: result.success, messageId: result.messageId, token, actionUrl: verifyUrl }
4028
+ : { ok: result.success, messageId: result.messageId });
3996
4029
  });
3997
4030
 
3998
4031
  // POST /verify-email — KV token→shardId lookup → direct Shard call
@@ -4123,8 +4156,7 @@ authRoute.openapi(requestPasswordReset, async (c) => {
4123
4156
 
4124
4157
  const provider = createEmailProvider(getEmailConfig(c.env), c.env);
4125
4158
  if (!provider) {
4126
- const release = parseConfig(c.env)?.release ?? false;
4127
- if (!release) {
4159
+ if (shouldExposeAuthTestSecrets(c.env) || !isReleaseMode(c.env)) {
4128
4160
  console.warn('[Auth] Email provider not configured. Reset email not sent. Token:', token);
4129
4161
  return c.json({ ok: true, message: 'Email provider not configured.', token, actionUrl: resetUrl });
4130
4162
  }
@@ -4145,7 +4177,9 @@ authRoute.openapi(requestPasswordReset, async (c) => {
4145
4177
  resolveSubject(c.env, 'passwordReset', defaultSubject, locale), html, locale,
4146
4178
  );
4147
4179
 
4148
- return c.json({ ok: result.success, messageId: result.messageId });
4180
+ return c.json(shouldExposeAuthTestSecrets(c.env)
4181
+ ? { ok: result.success, messageId: result.messageId, token, actionUrl: resetUrl }
4182
+ : { ok: result.success, messageId: result.messageId });
4149
4183
  });
4150
4184
 
4151
4185
  // POST /reset-password — KV token→shardId lookup → direct Shard call
@@ -70,6 +70,12 @@ const roomRealtimeCreateSessionBodySchema = z.object({
70
70
  thirdparty: z.boolean().optional().openapi({ description: 'Forward Cloudflare Realtime thirdparty mode' }),
71
71
  sessionDescription: roomRealtimeSessionDescriptionSchema.optional(),
72
72
  });
73
+ const roomCloudflareRealtimeKitCreateSessionBodySchema = z.object({
74
+ connectionId: z.string().optional().openapi({ description: 'Specific room connection ID to bind the Cloudflare RealtimeKit participant to' }),
75
+ customParticipantId: z.string().optional().openapi({ description: 'Optional custom participant identifier for the provisioned RealtimeKit participant' }),
76
+ name: z.string().optional().openapi({ description: 'Optional display name for the provisioned RealtimeKit participant' }),
77
+ picture: z.string().optional().openapi({ description: 'Optional avatar URL for the provisioned RealtimeKit participant' }),
78
+ });
73
79
  const roomRealtimeCreateSessionResponseSchema = z.object({
74
80
  sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
75
81
  sessionDescription: roomRealtimeSessionDescriptionSchema.optional(),
@@ -78,6 +84,15 @@ const roomRealtimeCreateSessionResponseSchema = z.object({
78
84
  connectionId: z.string().optional().openapi({ description: 'Room connection ID associated with the session' }),
79
85
  reused: z.boolean().optional().openapi({ description: 'Whether an existing provider session was reused' }),
80
86
  });
87
+ const roomCloudflareRealtimeKitCreateSessionResponseSchema = z.object({
88
+ sessionId: z.string().openapi({ description: 'Cloudflare RealtimeKit participant ID' }),
89
+ meetingId: z.string().openapi({ description: 'Cloudflare RealtimeKit meeting ID backing the room session' }),
90
+ participantId: z.string().openapi({ description: 'Cloudflare RealtimeKit participant ID' }),
91
+ authToken: z.string().openapi({ description: 'RealtimeKit auth token for the provisioned participant' }),
92
+ presetName: z.string().optional().openapi({ description: 'RealtimeKit preset used for the provisioned participant' }),
93
+ connectionId: z.string().optional().openapi({ description: 'Room connection ID associated with the session' }),
94
+ reused: z.boolean().optional().openapi({ description: 'Whether an existing provider participant was reused' }),
95
+ });
81
96
  const roomRealtimeSessionStateSchema = z.object({
82
97
  sessionId: z.string().openapi({ description: 'Realtime provider session ID' }),
83
98
  connectionId: z.string().optional().openapi({ description: 'Room connection ID associated with the session' }),
@@ -525,6 +540,27 @@ const createRoomRealtimeSession = createRoute({
525
540
  },
526
541
  });
527
542
 
543
+ const createRoomCloudflareRealtimeKitSession = createRoute({
544
+ operationId: 'createRoomCloudflareRealtimeKitSession',
545
+ method: 'post',
546
+ path: '/media/cloudflare_realtimekit/session',
547
+ tags: ['client'],
548
+ summary: 'Create a room Cloudflare RealtimeKit session',
549
+ description: 'Creates a Cloudflare RealtimeKit session for the authenticated room member.',
550
+ request: {
551
+ query: roomQuerySchema,
552
+ body: { content: { 'application/json': { schema: roomCloudflareRealtimeKitCreateSessionBodySchema } }, required: false },
553
+ },
554
+ responses: {
555
+ 200: { description: 'Cloudflare RealtimeKit session created', content: { 'application/json': { schema: roomCloudflareRealtimeKitCreateSessionResponseSchema } } },
556
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
557
+ 401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
558
+ 403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
559
+ 404: { description: 'Room runtime not found', content: { 'application/json': { schema: errorResponseSchema } } },
560
+ 409: { description: 'Conflicting existing published media', content: { 'application/json': { schema: errorResponseSchema } } },
561
+ },
562
+ });
563
+
528
564
  const createRoomRealtimeIceServers = createRoute({
529
565
  operationId: 'createRoomRealtimeIceServers',
530
566
  method: 'post',
@@ -637,3 +673,9 @@ roomRoute.openapi(closeRoomRealtimeTracks, async (c) =>
637
673
  requireAuth: true,
638
674
  validatedJson: c.req.valid('json'),
639
675
  }));
676
+
677
+ roomRoute.openapi(createRoomCloudflareRealtimeKitSession, async (c) =>
678
+ proxyRoomDoRequest(c, '/media/cloudflare_realtimekit/session', 'POST', {
679
+ requireAuth: true,
680
+ validatedJson: c.req.valid('json'),
681
+ }));