@takosjp/yurucommu-core 3.2.1 → 3.3.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Call signaling wire contract (voice + video).
3
+ *
4
+ * SINGLE SOURCE OF TRUTH shared by:
5
+ * - the backend cross-instance signaling ingest (`/ap/rtc/signal`) + the
6
+ * Signaling Durable Object (server-to-server + browser fan-out), and
7
+ * - the browser `CallClient` (`../lib/rtc-client.ts`).
8
+ *
9
+ * Kept deliberately DOM-structural (no `RTCIceCandidateInit` / `RTCIceServer`
10
+ * imports) so the same file type-checks in the server context (which never runs
11
+ * the browser WebRTC APIs) and the browser bundle. The `CallClient` maps these
12
+ * structural shapes to/from the real DOM `RTCSessionDescriptionInit` /
13
+ * `RTCIceCandidateInit` / `RTCIceServer`, which are structurally compatible.
14
+ *
15
+ * Design: signaling travels over federation (server-to-server, HTTP-Signature
16
+ * authenticated) as `RtcSignalEnvelopeV1`; media is P2P WebRTC + STUN/TURN for
17
+ * 1:1 (`sfuFocus: null`) and a pluggable WHIP/WHEP SFU focus for group calls.
18
+ */
19
+
20
+ export const RTC_SIGNAL_ENVELOPE_VERSION = 1 as const;
21
+
22
+ /** Which media tracks a call carries. `video:false` => audio-only call. */
23
+ export interface CallMediaKind {
24
+ audio: boolean;
25
+ video: boolean;
26
+ }
27
+
28
+ /** Cross-instance signaling message kinds (Matrix-VoIP inspired). */
29
+ export type RtcSignalType =
30
+ | "offer"
31
+ | "answer"
32
+ | "candidate"
33
+ | "accept"
34
+ | "reject"
35
+ | "hangup"
36
+ | "cancel";
37
+
38
+ /**
39
+ * Selected SFU focus for a group call. `null`/absent means pure P2P (1:1).
40
+ * `kind` names the adapter (`whip` / `livekit` / `cloudflare-realtime` / ...);
41
+ * the client talks WHIP/WHEP so the SFU backend stays vendor-neutral.
42
+ */
43
+ export interface SfuFocus {
44
+ kind: string;
45
+ /** WHIP (publish) / WHEP (subscribe) endpoint base, or SFU signaling URL. */
46
+ url: string;
47
+ /** Short-lived join token when the adapter requires one. */
48
+ token?: string;
49
+ room?: string;
50
+ }
51
+
52
+ /** Structural mirror of `RTCIceCandidateInit` (no DOM dependency). */
53
+ export interface CallIceCandidate {
54
+ candidate: string;
55
+ sdpMid?: string | null;
56
+ sdpMLineIndex?: number | null;
57
+ usernameFragment?: string | null;
58
+ }
59
+
60
+ /** Structural mirror of `RTCIceServer` (no DOM dependency). */
61
+ export interface IceServerConfig {
62
+ urls: string | string[];
63
+ username?: string;
64
+ credential?: string;
65
+ }
66
+
67
+ /**
68
+ * Server-to-server signaling envelope. Delivered by the sending instance to the
69
+ * recipient instance's `/ap/rtc/signal` endpoint, signed with the sender actor's
70
+ * HTTP Signature key (keyId-owner === `from`). `callId` doubles as the anti-
71
+ * replay nonce; `ts`/`ttlMs` bound its freshness (the DO drops stale frames).
72
+ */
73
+ export interface RtcSignalEnvelopeV1 {
74
+ v: typeof RTC_SIGNAL_ENVELOPE_VERSION;
75
+ callId: string;
76
+ from: string;
77
+ to: string;
78
+ type: RtcSignalType;
79
+ media?: CallMediaKind;
80
+ /** SDP for `offer` / `answer`. */
81
+ sdp?: string;
82
+ /** Half-trickle ICE bundle for `offer` / `answer` / `candidate`. */
83
+ candidates?: CallIceCandidate[];
84
+ sfuFocus?: SfuFocus | null;
85
+ /** Free-text end/reject reason (`busy`, `declined`, `timeout`, ...). */
86
+ reason?: string;
87
+ ts: number;
88
+ ttlMs: number;
89
+ }
90
+
91
+ /** Lifecycle of a single call, mirrored client-side and in `call_sessions`. */
92
+ export type CallState =
93
+ | "idle"
94
+ | "ringing"
95
+ | "connecting"
96
+ | "connected"
97
+ | "ended"
98
+ | "rejected"
99
+ | "missed"
100
+ | "failed"
101
+ | "cancelled";
102
+
103
+ export type CallDirection = "incoming" | "outgoing";
104
+
105
+ /** Terminal states — a call in one of these is over and not resumable. */
106
+ export const TERMINAL_CALL_STATES: readonly CallState[] = [
107
+ "ended",
108
+ "rejected",
109
+ "missed",
110
+ "failed",
111
+ "cancelled",
112
+ ];
113
+
114
+ export function isTerminalCallState(state: CallState): boolean {
115
+ return TERMINAL_CALL_STATES.includes(state);
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Browser <-> Signaling Durable Object WebSocket frames
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** Frames the browser sends up to its own instance's Signaling DO. */
123
+ export type ClientToHubFrame =
124
+ | { t: "hello" }
125
+ | { t: "invite"; callId: string; to: string; media: CallMediaKind }
126
+ | { t: "offer"; callId: string; sdp: string }
127
+ | { t: "answer"; callId: string; sdp: string }
128
+ | { t: "candidates"; callId: string; candidates: CallIceCandidate[] }
129
+ | { t: "accept"; callId: string }
130
+ | { t: "reject"; callId: string; reason?: string }
131
+ | { t: "hangup"; callId: string; reason?: string }
132
+ | { t: "resume"; callId: string }
133
+ | { t: "ping" };
134
+
135
+ /** Frames the Signaling DO pushes down to the browser. */
136
+ export type HubToClientFrame =
137
+ | { t: "ready" }
138
+ | { t: "ringing"; callId: string; from: string; media: CallMediaKind }
139
+ | { t: "offer"; callId: string; sdp: string; media?: CallMediaKind }
140
+ | { t: "answer"; callId: string; sdp: string }
141
+ | { t: "candidates"; callId: string; candidates: CallIceCandidate[] }
142
+ | { t: "peer-accepted"; callId: string }
143
+ | { t: "peer-rejected"; callId: string; reason?: string }
144
+ | { t: "peer-hangup"; callId: string; reason?: string }
145
+ | {
146
+ t: "ice-servers";
147
+ callId: string;
148
+ iceServers: IceServerConfig[];
149
+ sfuFocus?: SfuFocus | null;
150
+ }
151
+ | { t: "call-state"; callId: string; state: CallState }
152
+ | { t: "pong" }
153
+ | { t: "error"; code: string; message?: string };
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // REST contract (start call / mint ICE / call history)
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export interface StartCallRequest {
160
+ to: string;
161
+ media: CallMediaKind;
162
+ }
163
+
164
+ export interface StartCallResponse {
165
+ callId: string;
166
+ iceServers: IceServerConfig[];
167
+ sfuFocus?: SfuFocus | null;
168
+ }
169
+
170
+ export interface IceServersResponse {
171
+ iceServers: IceServerConfig[];
172
+ }
173
+
174
+ export interface CallSessionSummary {
175
+ id: string;
176
+ peer: string;
177
+ direction: CallDirection;
178
+ state: CallState;
179
+ media: CallMediaKind;
180
+ createdAt: string;
181
+ connectedAt?: string | null;
182
+ endedAt?: string | null;
183
+ endReason?: string | null;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Runtime validation (used by the backend ingest to reject malformed frames)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ const SIGNAL_TYPES: readonly RtcSignalType[] = [
191
+ "offer",
192
+ "answer",
193
+ "candidate",
194
+ "accept",
195
+ "reject",
196
+ "hangup",
197
+ "cancel",
198
+ ];
199
+
200
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
201
+ return typeof value === "object" && value !== null && !Array.isArray(value);
202
+ }
203
+
204
+ function isCallMediaKind(value: unknown): value is CallMediaKind {
205
+ return (
206
+ isPlainObject(value) &&
207
+ typeof value.audio === "boolean" &&
208
+ typeof value.video === "boolean"
209
+ );
210
+ }
211
+
212
+ function parseCandidates(value: unknown): CallIceCandidate[] | undefined {
213
+ if (value === undefined) return undefined;
214
+ if (!Array.isArray(value)) return undefined;
215
+ const out: CallIceCandidate[] = [];
216
+ for (const raw of value) {
217
+ if (!isPlainObject(raw) || typeof raw.candidate !== "string") continue;
218
+ out.push({
219
+ candidate: raw.candidate,
220
+ sdpMid: typeof raw.sdpMid === "string" ? raw.sdpMid : null,
221
+ sdpMLineIndex:
222
+ typeof raw.sdpMLineIndex === "number" ? raw.sdpMLineIndex : null,
223
+ usernameFragment:
224
+ typeof raw.usernameFragment === "string" ? raw.usernameFragment : null,
225
+ });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function parseSfuFocus(value: unknown): SfuFocus | null | undefined {
231
+ if (value === undefined) return undefined;
232
+ if (value === null) return null;
233
+ if (!isPlainObject(value)) return undefined;
234
+ if (typeof value.kind !== "string" || typeof value.url !== "string") {
235
+ return undefined;
236
+ }
237
+ return {
238
+ kind: value.kind,
239
+ url: value.url,
240
+ token: typeof value.token === "string" ? value.token : undefined,
241
+ room: typeof value.room === "string" ? value.room : undefined,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Parse + validate an inbound cross-instance signaling envelope. Returns the
247
+ * normalized envelope or `null` when the shape is invalid. Callers additionally
248
+ * enforce that the HTTP-Signature signer equals `from` and that the recipient
249
+ * (`to`) is a local actor.
250
+ */
251
+ export function parseRtcSignalEnvelope(
252
+ input: unknown,
253
+ ): RtcSignalEnvelopeV1 | null {
254
+ if (!isPlainObject(input)) return null;
255
+ if (input.v !== RTC_SIGNAL_ENVELOPE_VERSION) return null;
256
+ const { callId, from, to, type, ts, ttlMs } = input;
257
+ if (
258
+ typeof callId !== "string" ||
259
+ callId.length === 0 ||
260
+ callId.length > 200 ||
261
+ typeof from !== "string" ||
262
+ from.length === 0 ||
263
+ typeof to !== "string" ||
264
+ to.length === 0 ||
265
+ typeof type !== "string" ||
266
+ !SIGNAL_TYPES.includes(type as RtcSignalType) ||
267
+ typeof ts !== "number" ||
268
+ !Number.isFinite(ts) ||
269
+ typeof ttlMs !== "number" ||
270
+ !Number.isFinite(ttlMs) ||
271
+ ttlMs < 0
272
+ ) {
273
+ return null;
274
+ }
275
+ const sdp = typeof input.sdp === "string" ? input.sdp : undefined;
276
+ // Guard against absurd SDP blobs abusing the endpoint as a relay.
277
+ if (sdp !== undefined && sdp.length > 100_000) return null;
278
+ return {
279
+ v: RTC_SIGNAL_ENVELOPE_VERSION,
280
+ callId,
281
+ from,
282
+ to,
283
+ type: type as RtcSignalType,
284
+ media: isCallMediaKind(input.media) ? input.media : undefined,
285
+ sdp,
286
+ candidates: parseCandidates(input.candidates),
287
+ sfuFocus: parseSfuFocus(input.sfuFocus),
288
+ reason:
289
+ typeof input.reason === "string"
290
+ ? input.reason.slice(0, 200)
291
+ : undefined,
292
+ ts,
293
+ ttlMs,
294
+ };
295
+ }
296
+
297
+ /** True when the envelope is still within its freshness window. */
298
+ export function isEnvelopeFresh(
299
+ envelope: RtcSignalEnvelopeV1,
300
+ now: number,
301
+ ): boolean {
302
+ // Reject frames from the future (clock skew tolerance) or past their TTL.
303
+ const skewToleranceMs = 30_000;
304
+ if (envelope.ts - now > skewToleranceMs) return false;
305
+ return now - envelope.ts <= envelope.ttlMs;
306
+ }
@@ -1,3 +1,6 @@
1
+ // Call signaling wire contract + browser<->hub frames (voice + video).
2
+ export * from "./call.ts";
3
+
1
4
  // ===== Yurucommu AP-Native Types =====
2
5
 
3
6
  // Actor represents a user (Person) in ActivityPub
@@ -29,6 +29,7 @@ import { moderationRoutes } from "./routes/moderation.ts";
29
29
  import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
30
30
  import mobileRoutes from "./routes/mobile.ts";
31
31
  import notificationPusherRoutes from "./routes/notification-pushers.ts";
32
+ import rtcRoutes from "./routes/rtc/index.ts";
32
33
 
33
34
  import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
34
35
  import { csrfProtection } from "./middleware/csrf.ts";
@@ -254,6 +255,7 @@ function buildSocialServerDiscovery(
254
255
  authProviders: `${appUrl}/api/auth/providers`,
255
256
  mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
256
257
  mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
258
+ mobileLogout: `${appUrl}/api/auth/logout`,
257
259
  currentUser: `${appUrl}/api/auth/me`,
258
260
  timeline: `${appUrl}/api/timeline`,
259
261
  conversations: `${appUrl}/api/dm/contacts`,
@@ -506,6 +508,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
506
508
  app.use("*", async (c, next) => {
507
509
  await next();
508
510
 
511
+ // A 101 Switching Protocols response carries a WebSocket and immutable
512
+ // headers (the /api/rtc/socket call upgrade). Mutating it throws and breaks
513
+ // the upgrade, so skip the security-header pass for it.
514
+ if (c.res.status === 101) return;
515
+
509
516
  const preserveRouteSecurityHeaders = c.req.path.startsWith("/hosted/");
510
517
  const setSecurityHeader = (name: string, value: string) => {
511
518
  if (preserveRouteSecurityHeaders && c.res.headers.has(name)) {
@@ -553,9 +560,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
553
560
  setSecurityHeader("X-Content-Type-Options", "nosniff");
554
561
  setSecurityHeader("X-Frame-Options", "DENY");
555
562
  setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
563
+ // Allow the app's OWN origin to use camera + microphone (WebRTC calls);
564
+ // still deny geolocation and deny camera/mic to any cross-origin frame.
556
565
  setSecurityHeader(
557
566
  "Permissions-Policy",
558
- "camera=(), microphone=(), geolocation=()",
567
+ "camera=(self), microphone=(self), geolocation=()",
559
568
  );
560
569
  // HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
561
570
  // (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
@@ -693,6 +702,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
693
702
  app.use(pattern, rateLimit(RateLimitConfigs.inbox));
694
703
  }
695
704
 
705
+ // Cross-instance call signaling ingest is unauthenticated at the network edge
706
+ // (it verifies an HTTP Signature inside the handler) and can be hit by any
707
+ // remote instance, so throttle it per-IP like the other federation endpoints.
708
+ app.use("/ap/rtc/signal", rateLimit(RateLimitConfigs.federationDiscovery));
709
+
696
710
  // Federation discovery endpoints are unauthenticated and can be probed by
697
711
  // any remote actor. Throttle them per-IP to mitigate enumeration / DoS.
698
712
  app.use(
@@ -766,6 +780,8 @@ function mountCoreRoutes(app: YurucommuApp): void {
766
780
  app.route("/api/moderation", moderationRoutes);
767
781
  app.route("/api/apps", appsApiRoutes);
768
782
  app.route("/hosted", appsServeRoutes);
783
+ // Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
784
+ app.route("/", rtcRoutes);
769
785
  app.route("/", activitypubRoutes);
770
786
  }
771
787
 
@@ -933,6 +949,10 @@ type WorkerBindings = EnvVars & {
933
949
  ASSETS?: Fetcher;
934
950
  DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
935
951
  DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
952
+ // Signaling hub Durable Object namespace (call feature). wrapCloudflareBindings
953
+ // spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
954
+ // the rtc routes read it as c.env.CALL_SIGNALING.
955
+ CALL_SIGNALING?: DurableObjectNamespace;
936
956
  };
937
957
 
938
958
  export default {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Durable persistence for call sessions (history / missed-call / current state).
3
+ * Ephemeral SDP/ICE never touches this — only the call lifecycle does.
4
+ */
5
+
6
+ import { and, desc, eq } from "drizzle-orm";
7
+ import type { Database } from "../../../db/index.ts";
8
+ import { callSessions, nowIso } from "../../../db/index.ts";
9
+ import type {
10
+ CallSessionSummary,
11
+ CallDirection,
12
+ CallState,
13
+ } from "../../../../packages/api/src/types/call.ts";
14
+ import { isTerminalCallState } from "../../../../packages/api/src/types/call.ts";
15
+ import type { CallRecord } from "../../runtime/call-hub-core.ts";
16
+
17
+ /** Insert or update the durable row for a call transition. */
18
+ export async function upsertCallSession(
19
+ db: Database,
20
+ localActorApId: string,
21
+ call: CallRecord,
22
+ ): Promise<void> {
23
+ const now = nowIso();
24
+ const terminal = isTerminalCallState(call.state);
25
+ const connectedAt = call.connectedAt
26
+ ? new Date(call.connectedAt).toISOString()
27
+ : null;
28
+ const sfuFocus = call.sfuFocus ? JSON.stringify(call.sfuFocus) : null;
29
+ await db
30
+ .insert(callSessions)
31
+ .values({
32
+ id: call.callId,
33
+ localActorApId,
34
+ peerActorApId: call.peerApId,
35
+ direction: call.direction,
36
+ state: call.state,
37
+ mediaAudio: call.media.audio ? 1 : 0,
38
+ mediaVideo: call.media.video ? 1 : 0,
39
+ sfuFocus,
40
+ peerSignalEndpoint: call.peerSignalEndpoint ?? null,
41
+ connectedAt,
42
+ endedAt: terminal ? now : null,
43
+ })
44
+ .onConflictDoUpdate({
45
+ target: callSessions.id,
46
+ set: {
47
+ state: call.state,
48
+ sfuFocus,
49
+ peerSignalEndpoint: call.peerSignalEndpoint ?? null,
50
+ connectedAt,
51
+ endedAt: terminal ? now : null,
52
+ updatedAt: now,
53
+ },
54
+ });
55
+ }
56
+
57
+ function toSummary(row: typeof callSessions.$inferSelect): CallSessionSummary {
58
+ return {
59
+ id: row.id,
60
+ peer: row.peerActorApId,
61
+ direction: row.direction as CallDirection,
62
+ state: row.state as CallState,
63
+ media: { audio: row.mediaAudio === 1, video: row.mediaVideo === 1 },
64
+ createdAt: row.createdAt,
65
+ connectedAt: row.connectedAt,
66
+ endedAt: row.endedAt,
67
+ endReason: row.endReason,
68
+ };
69
+ }
70
+
71
+ export async function listCallSessions(
72
+ db: Database,
73
+ localActorApId: string,
74
+ limit = 50,
75
+ ): Promise<CallSessionSummary[]> {
76
+ const rows = await db
77
+ .select()
78
+ .from(callSessions)
79
+ .where(eq(callSessions.localActorApId, localActorApId))
80
+ .orderBy(desc(callSessions.createdAt))
81
+ .limit(Math.min(Math.max(limit, 1), 200));
82
+ return rows.map(toSummary);
83
+ }
84
+
85
+ export async function getCallSession(
86
+ db: Database,
87
+ localActorApId: string,
88
+ callId: string,
89
+ ): Promise<CallSessionSummary | null> {
90
+ const row = await db
91
+ .select()
92
+ .from(callSessions)
93
+ .where(
94
+ and(
95
+ eq(callSessions.id, callId),
96
+ eq(callSessions.localActorApId, localActorApId),
97
+ ),
98
+ )
99
+ .get();
100
+ return row ? toSummary(row) : null;
101
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * RtcProvider — media-plane configuration for the call feature.
3
+ *
4
+ * This is the seam that keeps calling vendor-neutral: 1:1 calls are pure P2P
5
+ * WebRTC over the operator-configured STUN/TURN servers (no SFU, no Cloudflare),
6
+ * and group calls (Phase 3) select a WHIP/WHEP SFU "focus" whose backend is any
7
+ * of `whip` / `livekit` / `cloudflare-realtime` — Cloudflare Realtime is one
8
+ * adapter among equals, never required.
9
+ *
10
+ * TURN credentials, when coturn's REST scheme is configured, are minted
11
+ * per-request as short-lived HMAC creds (RFC 8489 long-term-credential via the
12
+ * `turn-rest` `timestamp:name` username convention) — no static long-lived
13
+ * secret is ever handed to a client.
14
+ */
15
+
16
+ import type {
17
+ CallMediaKind,
18
+ IceServerConfig,
19
+ SfuFocus,
20
+ } from "../../../../packages/api/src/types/call.ts";
21
+ import type { EnvVars } from "../../types.ts";
22
+ import { bufferToBase64 } from "../base64.ts";
23
+ import { logger } from "../logger.ts";
24
+
25
+ const log = logger.child({ component: "rtc.provider" });
26
+
27
+ const DEFAULT_TURN_TTL_SECONDS = 3600;
28
+
29
+ export interface RtcProvider {
30
+ /** ICE (STUN/TURN) servers for a call. Fresh (short-lived) TURN creds. */
31
+ getIceServers(): Promise<IceServerConfig[]>;
32
+ /** Group-call SFU focus, or null for pure P2P (always null for 1:1). */
33
+ getSfuFocus(media: CallMediaKind): Promise<SfuFocus | null>;
34
+ }
35
+
36
+ function parseStaticIceServers(raw: string | undefined): IceServerConfig[] {
37
+ if (!raw?.trim()) return [];
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ if (!Array.isArray(parsed)) return [];
41
+ const out: IceServerConfig[] = [];
42
+ for (const entry of parsed) {
43
+ if (!entry || typeof entry !== "object") continue;
44
+ const urls = (entry as { urls?: unknown }).urls;
45
+ if (typeof urls !== "string" && !Array.isArray(urls)) continue;
46
+ const server: IceServerConfig = { urls: urls as string | string[] };
47
+ const username = (entry as { username?: unknown }).username;
48
+ const credential = (entry as { credential?: unknown }).credential;
49
+ if (typeof username === "string") server.username = username;
50
+ if (typeof credential === "string") server.credential = credential;
51
+ out.push(server);
52
+ }
53
+ return out;
54
+ } catch (err) {
55
+ log.warn("Invalid YURUCOMMU_RTC_ICE_SERVERS JSON", { error: String(err) });
56
+ return [];
57
+ }
58
+ }
59
+
60
+ async function mintTurnCredential(
61
+ uris: string[],
62
+ secret: string,
63
+ ttlSeconds: number,
64
+ ): Promise<IceServerConfig | null> {
65
+ if (uris.length === 0 || !secret) return null;
66
+ // coturn REST: username = "<unix-expiry>:<name>", credential = base64(HMAC-
67
+ // SHA1(secret, username)). A random name keeps creds unlinkable per call.
68
+ const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
69
+ const username = `${expiry}:yurucommu`;
70
+ try {
71
+ const key = await crypto.subtle.importKey(
72
+ "raw",
73
+ new TextEncoder().encode(secret),
74
+ { name: "HMAC", hash: "SHA-1" },
75
+ false,
76
+ ["sign"],
77
+ );
78
+ const mac = await crypto.subtle.sign(
79
+ "HMAC",
80
+ key,
81
+ new TextEncoder().encode(username),
82
+ );
83
+ return { urls: uris, username, credential: bufferToBase64(mac) };
84
+ } catch (err) {
85
+ log.error("Failed to mint TURN credential", { error: String(err) });
86
+ return null;
87
+ }
88
+ }
89
+
90
+ class ConfiguredRtcProvider implements RtcProvider {
91
+ constructor(private readonly env: EnvVars) {}
92
+
93
+ async getIceServers(): Promise<IceServerConfig[]> {
94
+ const servers = parseStaticIceServers(this.env.YURUCOMMU_RTC_ICE_SERVERS);
95
+ const uris = (this.env.YURUCOMMU_RTC_TURN_URIS ?? "")
96
+ .split(",")
97
+ .map((s) => s.trim())
98
+ .filter(Boolean);
99
+ const secret = this.env.YURUCOMMU_RTC_TURN_SECRET?.trim();
100
+ if (uris.length > 0 && secret) {
101
+ const ttl =
102
+ Number.parseInt(this.env.YURUCOMMU_RTC_TURN_TTL ?? "", 10) ||
103
+ DEFAULT_TURN_TTL_SECONDS;
104
+ const turn = await mintTurnCredential(uris, secret, ttl);
105
+ if (turn) servers.push(turn);
106
+ }
107
+ return servers;
108
+ }
109
+
110
+ async getSfuFocus(_media: CallMediaKind): Promise<SfuFocus | null> {
111
+ const adapter = (this.env.YURUCOMMU_RTC_SFU_ADAPTER ?? "p2p")
112
+ .trim()
113
+ .toLowerCase();
114
+ if (adapter === "" || adapter === "p2p") return null;
115
+ const url = this.env.YURUCOMMU_RTC_SFU_URL?.trim();
116
+ if (!url) {
117
+ log.warn("SFU adapter selected but YURUCOMMU_RTC_SFU_URL unset", {
118
+ adapter,
119
+ });
120
+ return null;
121
+ }
122
+ // WHIP/WHEP focus passthrough. Real per-room token minting (LiveKit JWT,
123
+ // Cloudflare Realtime app tokens) lands with group calls (Phase 3); today
124
+ // the shared/static token (if any) is advertised as-is.
125
+ return {
126
+ kind: adapter,
127
+ url,
128
+ token: this.env.YURUCOMMU_RTC_SFU_TOKEN?.trim() || undefined,
129
+ };
130
+ }
131
+ }
132
+
133
+ export function createRtcProvider(env: EnvVars): RtcProvider {
134
+ return new ConfiguredRtcProvider(env);
135
+ }