@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,125 @@
1
+ /**
2
+ * Cross-instance call signaling transport (server-to-server).
3
+ *
4
+ * Signaling for a call between two self-hosted instances travels as an
5
+ * HTTP-Signature-signed POST to the peer instance's `/ap/rtc/signal` endpoint.
6
+ * This deliberately does NOT use the queued federation delivery path
7
+ * (`enqueueDeliveryToActor`, with its circuit-breaker + retry/backoff): an SDP
8
+ * offer or ICE candidate is ephemeral and latency-sensitive, so a stale retry is
9
+ * useless. We reuse the same low-level signing (`signRequest`) + SSRF-guarded
10
+ * fetch (`fetchWithTimeout`) primitives the delivery worker uses, but send
11
+ * synchronously and directly. It also does NOT go through the inbox activity
12
+ * pipeline (`claimActivityForDispatch` / `parseActivity`), which would both strip
13
+ * the SDP/ICE fields and pollute the `activities` ledger with ephemeral frames.
14
+ */
15
+
16
+ import { eq } from "drizzle-orm";
17
+ import type { Database } from "../../../db/index.ts";
18
+ import { actorCache } from "../../../db/index.ts";
19
+ import type { RtcSignalEnvelopeV1 } from "../../../../packages/api/src/types/call.ts";
20
+ import { signRequest } from "../ap-signing.ts";
21
+ import { fetchWithTimeout } from "../federation-fetch.ts";
22
+ import { fetchAndUpsertActorCache } from "../activitypub-actor-cache.ts";
23
+ import { isSafeRemoteUrl } from "../ssrf.ts";
24
+ import { logger } from "../logger.ts";
25
+
26
+ const log = logger.child({ component: "rtc.signal-transport" });
27
+
28
+ const SIGNAL_PATH = "/ap/rtc/signal";
29
+ const SIGNAL_TIMEOUT_MS = 8000;
30
+
31
+ export interface CallSigner {
32
+ apId: string;
33
+ privateKeyPem: string;
34
+ }
35
+
36
+ /** Derive a peer's signaling endpoint from a cached actor row. */
37
+ function endpointFromActorRow(row: {
38
+ inbox: string;
39
+ rawJson: string;
40
+ }): string | null {
41
+ // Prefer an explicitly advertised endpoint (actor `endpoints.rtcSignal`).
42
+ try {
43
+ const doc = JSON.parse(row.rawJson) as {
44
+ endpoints?: { rtcSignal?: unknown };
45
+ };
46
+ const advertised = doc.endpoints?.rtcSignal;
47
+ if (typeof advertised === "string" && isSafeRemoteUrl(advertised)) {
48
+ return advertised;
49
+ }
50
+ } catch {
51
+ // fall through to inbox-origin derivation
52
+ }
53
+ // Fall back to `<inbox-origin>/ap/rtc/signal` (every yurucommu instance
54
+ // serves this path). Peers that are not yurucommu simply won't answer.
55
+ try {
56
+ const origin = new URL(row.inbox).origin;
57
+ const endpoint = `${origin}${SIGNAL_PATH}`;
58
+ return isSafeRemoteUrl(endpoint) ? endpoint : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Resolve (and cache) the peer instance's signaling endpoint URL. */
65
+ export async function resolvePeerSignalEndpoint(
66
+ db: Database,
67
+ peerApId: string,
68
+ ): Promise<string | null> {
69
+ let row = await db.query.actorCache.findFirst({
70
+ where: eq(actorCache.apId, peerApId),
71
+ columns: { inbox: true, rawJson: true },
72
+ });
73
+ if (!row) {
74
+ const result = await fetchAndUpsertActorCache(db, peerApId, {});
75
+ if (result.ok) {
76
+ row = { inbox: result.row.inbox, rawJson: result.row.rawJson };
77
+ }
78
+ }
79
+ if (!row) return null;
80
+ return endpointFromActorRow(row);
81
+ }
82
+
83
+ /**
84
+ * Sign + POST a signaling envelope to the peer instance. Throws on any
85
+ * unreachable / non-2xx outcome so the caller can fail the call fast.
86
+ */
87
+ export async function sendCallSignal(
88
+ db: Database,
89
+ signer: CallSigner,
90
+ envelope: RtcSignalEnvelopeV1,
91
+ peerSignalEndpoint?: string,
92
+ ): Promise<void> {
93
+ const endpoint =
94
+ peerSignalEndpoint ?? (await resolvePeerSignalEndpoint(db, envelope.to));
95
+ if (!endpoint) {
96
+ throw new Error(`no signaling endpoint for ${envelope.to}`);
97
+ }
98
+ const body = JSON.stringify(envelope);
99
+ const keyId = `${signer.apId}#main-key`;
100
+ const signed = await signRequest(
101
+ signer.privateKeyPem,
102
+ keyId,
103
+ "POST",
104
+ endpoint,
105
+ body,
106
+ );
107
+ const res = await fetchWithTimeout(endpoint, {
108
+ method: "POST",
109
+ headers: {
110
+ ...signed,
111
+ "Content-Type": "application/activity+json",
112
+ Accept: "application/json",
113
+ },
114
+ body,
115
+ timeout: SIGNAL_TIMEOUT_MS,
116
+ });
117
+ if (!res.ok) {
118
+ log.warn("Signaling POST rejected", {
119
+ callId: envelope.callId,
120
+ type: envelope.type,
121
+ status: res.status,
122
+ });
123
+ throw new Error(`signal POST ${res.status}`);
124
+ }
125
+ }
@@ -13,6 +13,9 @@ export { default } from "./index.ts";
13
13
  export { default as app } from "./index.ts";
14
14
  export { type Database, getDb, getDbSQLite } from "../db/index.ts";
15
15
  export { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
16
+ // Call feature: the signaling Durable Object class each product's generated
17
+ // worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
18
+ export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
16
19
  export type { Env, EnvVars } from "./types.ts";
17
20
  export type {
18
21
  DeliveryDlqMessageV1,
@@ -440,9 +440,13 @@ ap.get(
440
440
  following: showCollections ? actor.followingUrl : undefined,
441
441
  // Advertise sharedInbox so remote servers can deduplicate fan-out
442
442
  // delivery (Mastodon convention). The endpoint accepts signed
443
- // activities just like the per-actor inbox.
443
+ // activities just like the per-actor inbox. `rtcSignal` advertises the
444
+ // call-signaling ingest so a caller's instance can reach this user
445
+ // directly (yurucommu extension; peers that don't recognize it just fall
446
+ // back to deriving it from the inbox origin).
444
447
  endpoints: {
445
448
  sharedInbox: `${baseUrl}/ap/inbox`,
449
+ rtcSignal: `${baseUrl}/ap/rtc/signal`,
446
450
  },
447
451
  publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
448
452
  discoverable: !actor.isPrivate,
@@ -63,6 +63,12 @@ const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
63
63
  // abuse even from the legitimate owner session (#23).
64
64
  const MAX_SUB_ACCOUNTS = 20;
65
65
 
66
+ function parsePassword(value: unknown): string | null {
67
+ // Passwords are opaque credentials. Trimming changes a valid secret and can
68
+ // make the native and browser login behavior diverge from the stored hash.
69
+ return typeof value === "string" && value.length > 0 ? value : null;
70
+ }
71
+
66
72
  // 認証設定取得
67
73
  auth.get("/providers", async (c) => {
68
74
  const config = getAuthConfig(c.env);
@@ -151,7 +157,7 @@ auth.post("/login", async (c) => {
151
157
  return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
152
158
  }
153
159
 
154
- const password = parseNonEmptyString(body.password);
160
+ const password = parsePassword(body.password);
155
161
  if (!password) {
156
162
  return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
157
163
  }
@@ -233,7 +239,7 @@ auth.post("/mobile/login", async (c) => {
233
239
  }
234
240
 
235
241
  const body = await parseJsonObject(c);
236
- const password = body ? parseNonEmptyString(body.password) : undefined;
242
+ const password = body ? parsePassword(body.password) : null;
237
243
  if (!password) {
238
244
  return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
239
245
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Call feature routes (WebRTC voice + video).
3
+ *
4
+ * POST /ap/rtc/signal server-to-server signaling ingest (HTTP-Signature)
5
+ * GET /api/rtc/socket browser WebSocket upgrade -> per-user signaling hub
6
+ * GET /api/rtc/ice mint short-lived ICE (STUN/TURN) servers
7
+ * POST /api/rtc/calls start a call (block-list gate + callId + ICE)
8
+ * GET /api/rtc/calls call history (missed / recent)
9
+ * GET /api/rtc/calls/:id current state of one call
10
+ *
11
+ * Signaling is intentionally OUTSIDE the ActivityPub inbox pipeline: the
12
+ * `/ap/rtc/signal` endpoint bypasses `claimActivityForDispatch` /
13
+ * `parseActivity` (which would strip SDP/ICE and persist ephemeral frames to the
14
+ * `activities` ledger). It reuses the same HTTP-Signature auth every inbox uses.
15
+ */
16
+
17
+ import { Hono } from "hono";
18
+ import { eq } from "drizzle-orm";
19
+ import type { Env, Variables } from "../../types.ts";
20
+ import { actors } from "../../../db/index.ts";
21
+ import { verifyHttpSignature } from "../../lib/ap-verify.ts";
22
+ import {
23
+ isActorMismatch,
24
+ signingActorFromKeyId,
25
+ } from "../activitypub/inbox.ts";
26
+ import { isActorBlocked } from "../../lib/blocklist.ts";
27
+ import {
28
+ getSignalingHub,
29
+ isSignalingAvailable,
30
+ } from "../../runtime/signaling-hub.ts";
31
+ import { createRtcProvider } from "../../lib/rtc/provider.ts";
32
+ import { getCallSession, listCallSessions } from "../../lib/rtc/call-store.ts";
33
+ import type {
34
+ CallMediaKind,
35
+ StartCallRequest,
36
+ } from "../../../../packages/api/src/types/call.ts";
37
+ import { parseRtcSignalEnvelope } from "../../../../packages/api/src/types/call.ts";
38
+
39
+ const rtc = new Hono<{ Bindings: Env; Variables: Variables }>();
40
+
41
+ function normalizeMedia(input: unknown): CallMediaKind {
42
+ if (input && typeof input === "object") {
43
+ const m = input as Partial<CallMediaKind>;
44
+ return { audio: m.audio !== false, video: Boolean(m.video) };
45
+ }
46
+ return { audio: true, video: false };
47
+ }
48
+
49
+ // --- Server-to-server signaling ingest -------------------------------------
50
+ rtc.post("/ap/rtc/signal", async (c) => {
51
+ const db = c.get("db");
52
+ const body = await c.req.text();
53
+ const sig = await verifyHttpSignature(c.req.raw, db, body);
54
+ if (!sig.valid) return c.json({ error: "invalid_signature" }, 401);
55
+
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = JSON.parse(body);
59
+ } catch {
60
+ return c.json({ error: "bad_json" }, 400);
61
+ }
62
+ const envelope = parseRtcSignalEnvelope(parsed);
63
+ if (!envelope) return c.json({ error: "bad_envelope" }, 400);
64
+
65
+ // The HTTP-Signature signer must own the claimed `from` actor.
66
+ if (isActorMismatch(signingActorFromKeyId(sig.keyId), envelope.from)) {
67
+ return c.json({ error: "signer_mismatch" }, 403);
68
+ }
69
+
70
+ // The recipient must be a local actor served by this instance.
71
+ const local = await db.query.actors.findFirst({
72
+ where: eq(actors.apId, envelope.to),
73
+ columns: { apId: true },
74
+ });
75
+ if (!local) return c.json({ error: "unknown_recipient" }, 404);
76
+
77
+ // Never ring for a sender the local owner has blocked; drop silently.
78
+ if (await isActorBlocked(db, envelope.from)) return c.body(null, 204);
79
+
80
+ await getSignalingHub(c.env).deliver(envelope.to, envelope);
81
+ return c.body(null, 204);
82
+ });
83
+
84
+ // --- Browser WebSocket upgrade ---------------------------------------------
85
+ rtc.get("/api/rtc/socket", async (c) => {
86
+ const actor = c.get("actor");
87
+ if (!actor) return c.json({ error: "unauthorized" }, 401);
88
+ if (!isSignalingAvailable(c.env)) {
89
+ return c.json({ error: "signaling_unavailable" }, 503);
90
+ }
91
+ return getSignalingHub(c.env).upgrade(c.req.raw, actor.ap_id);
92
+ });
93
+
94
+ // --- ICE servers ------------------------------------------------------------
95
+ rtc.get("/api/rtc/ice", async (c) => {
96
+ const actor = c.get("actor");
97
+ if (!actor) return c.json({ error: "unauthorized" }, 401);
98
+ const iceServers = await createRtcProvider(c.env).getIceServers();
99
+ return c.json({ iceServers });
100
+ });
101
+
102
+ // --- Start a call -----------------------------------------------------------
103
+ rtc.post("/api/rtc/calls", async (c) => {
104
+ const actor = c.get("actor");
105
+ if (!actor) return c.json({ error: "unauthorized" }, 401);
106
+ if (!isSignalingAvailable(c.env)) {
107
+ return c.json({ error: "signaling_unavailable" }, 503);
108
+ }
109
+ const db = c.get("db");
110
+ let payload: Partial<StartCallRequest>;
111
+ try {
112
+ payload = (await c.req.json()) as Partial<StartCallRequest>;
113
+ } catch {
114
+ return c.json({ error: "bad_json" }, 400);
115
+ }
116
+ const to = typeof payload.to === "string" ? payload.to.trim() : "";
117
+ if (!to || to === actor.ap_id) return c.json({ error: "bad_target" }, 400);
118
+ const media = normalizeMedia(payload.media);
119
+
120
+ // Do not let the local owner place a call to a contact they have blocked.
121
+ if (await isActorBlocked(db, to)) return c.json({ error: "blocked" }, 403);
122
+
123
+ const provider = createRtcProvider(c.env);
124
+ const [iceServers, sfuFocus] = await Promise.all([
125
+ provider.getIceServers(),
126
+ provider.getSfuFocus(media),
127
+ ]);
128
+ return c.json({ callId: crypto.randomUUID(), iceServers, sfuFocus });
129
+ });
130
+
131
+ // --- Call history + state ---------------------------------------------------
132
+ rtc.get("/api/rtc/calls", async (c) => {
133
+ const actor = c.get("actor");
134
+ if (!actor) return c.json({ error: "unauthorized" }, 401);
135
+ const calls = await listCallSessions(c.get("db"), actor.ap_id);
136
+ return c.json({ calls });
137
+ });
138
+
139
+ rtc.get("/api/rtc/calls/:id", async (c) => {
140
+ const actor = c.get("actor");
141
+ if (!actor) return c.json({ error: "unauthorized" }, 401);
142
+ const call = await getCallSession(c.get("db"), actor.ap_id, c.req.param("id"));
143
+ if (!call) return c.json({ error: "not_found" }, 404);
144
+ return c.json({ call });
145
+ });
146
+
147
+ export default rtc;