@takosjp/yurucommu-core 3.2.0 → 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.
Files changed (45) hide show
  1. package/migrations/0019_notification_push_delivery.sql +10 -7
  2. package/migrations/0020_call_sessions.sql +23 -0
  3. package/package.json +6 -2
  4. package/packages/api/package.json +1 -1
  5. package/packages/api/src/index.ts +1 -0
  6. package/packages/api/src/lib/api/browser-push.ts +11 -0
  7. package/packages/api/src/lib/api/normalize.ts +15 -4
  8. package/packages/api/src/lib/api/notification-target.ts +106 -0
  9. package/packages/api/src/lib/api/push-config.ts +132 -0
  10. package/packages/api/src/lib/api.ts +2 -0
  11. package/packages/api/src/lib/rtc-client.ts +542 -0
  12. package/packages/api/src/social-server.ts +7 -0
  13. package/packages/api/src/types/call.ts +306 -0
  14. package/packages/api/src/types/index.ts +16 -4
  15. package/src/backend/index.ts +65 -10
  16. package/src/backend/lib/attachments.ts +52 -0
  17. package/src/backend/lib/delivery/queue.ts +55 -0
  18. package/src/backend/lib/notification-eligibility.ts +150 -0
  19. package/src/backend/lib/notification-push.ts +159 -146
  20. package/src/backend/lib/rtc/call-store.ts +101 -0
  21. package/src/backend/lib/rtc/provider.ts +135 -0
  22. package/src/backend/lib/rtc/signal-transport.ts +125 -0
  23. package/src/backend/lib/session-actor.ts +16 -1
  24. package/src/backend/lib/unread-counts.ts +79 -0
  25. package/src/backend/middleware/csrf.ts +11 -0
  26. package/src/backend/public.ts +3 -0
  27. package/src/backend/routes/account-teardown.ts +13 -0
  28. package/src/backend/routes/activitypub.ts +5 -1
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +131 -3
  31. package/src/backend/routes/communities/messages.ts +16 -33
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +5 -39
  34. package/src/backend/routes/notifications.ts +27 -70
  35. package/src/backend/routes/posts/transformers.ts +8 -10
  36. package/src/backend/routes/rtc/index.ts +147 -0
  37. package/src/backend/runtime/call-hub-core.ts +470 -0
  38. package/src/backend/runtime/call-hub-port.ts +78 -0
  39. package/src/backend/runtime/call-signaling-do.ts +242 -0
  40. package/src/backend/runtime/signaling-hub.ts +187 -0
  41. package/src/backend/types.ts +28 -0
  42. package/src/db/index.ts +11 -10
  43. package/src/db/schema/calls.ts +46 -0
  44. package/src/db/schema/index.ts +1 -0
  45. package/src/db/schema/mobile.ts +7 -9
@@ -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
@@ -122,14 +125,23 @@ export interface Notification {
122
125
  type: "follow" | "follow_request" | "like" | "announce" | "reply" | "mention";
123
126
  actor: NotificationActor;
124
127
  object_ap_id: string | null;
125
- target_kind: "post" | "story" | "profile" | "community" | "notifications";
126
- target_id: string | null;
127
- /** Same-origin in-app path. Clients must not treat this as an external URL. */
128
- target_url: string;
128
+ /**
129
+ * Navigation target for the notification. OPTIONAL: a server older than
130
+ * 3.2.0 omits these fields, and `normalizeNotification` then synthesizes them
131
+ * from `object_ap_id`. Prefer `target_kind` + `target_id` and build your own
132
+ * in-app path; `target_url` is shaped for the yurucommu web client's routing
133
+ * and is same-origin only — never treat it as an external URL.
134
+ */
135
+ target_kind?: NotificationTargetKind;
136
+ target_id?: string | null;
137
+ target_url?: string;
129
138
  read: boolean;
130
139
  created_at: string;
131
140
  }
132
141
 
142
+ export type NotificationTargetKind =
143
+ "post" | "story" | "profile" | "notifications";
144
+
133
145
  export type NotificationPusherProduct = "yurucommu" | "yurume";
134
146
 
135
147
  export interface NotificationPusherInput {
@@ -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";
@@ -219,6 +220,7 @@ function buildSocialServerDiscovery(
219
220
  appUrl: string,
220
221
  issuer: string,
221
222
  options: YurucommuBackendDiscoveryOptionsV1 = {},
223
+ auth: { oidcClientId?: string; passwordEnabled?: boolean } = {},
222
224
  ) {
223
225
  const discovery = {
224
226
  ...DEFAULT_DISCOVERY_OPTIONS,
@@ -238,6 +240,11 @@ function buildSocialServerDiscovery(
238
240
  },
239
241
  clients: discovery.clients,
240
242
  issuer,
243
+ oidcClientId: auth.oidcClientId,
244
+ auth: {
245
+ oidc: Boolean(auth.oidcClientId),
246
+ password: Boolean(auth.passwordEnabled),
247
+ },
241
248
  apiBaseUrl: appUrl,
242
249
  activitypubOrigin: appUrl,
243
250
  mediaOrigin: `${appUrl}/media`,
@@ -246,6 +253,9 @@ function buildSocialServerDiscovery(
246
253
  endpoints: {
247
254
  api: `${appUrl}/api`,
248
255
  authProviders: `${appUrl}/api/auth/providers`,
256
+ mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
257
+ mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
258
+ mobileLogout: `${appUrl}/api/auth/logout`,
249
259
  currentUser: `${appUrl}/api/auth/me`,
250
260
  timeline: `${appUrl}/api/timeline`,
251
261
  conversations: `${appUrl}/api/dm/contacts`,
@@ -331,9 +341,19 @@ function mountReadinessRoutes(
331
341
  ) => {
332
342
  const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
333
343
  const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
334
- return c.json(buildSocialServerDiscovery(appUrl, issuer, discovery), 200, {
335
- "Cache-Control": "public, max-age=300",
336
- });
344
+ const { clientId } = getOidcClientCredentials(c.env);
345
+ return c.json(
346
+ buildSocialServerDiscovery(appUrl, issuer, discovery, {
347
+ oidcClientId: getOidcIssuerUrl(c.env)
348
+ ? clientId || undefined
349
+ : undefined,
350
+ passwordEnabled: Boolean(c.env.AUTH_PASSWORD_HASH?.trim()),
351
+ }),
352
+ 200,
353
+ {
354
+ "Cache-Control": "public, max-age=300",
355
+ },
356
+ );
337
357
  };
338
358
 
339
359
  app.get("/.well-known/yurucommu", wellKnownSocialServer);
@@ -488,6 +508,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
488
508
  app.use("*", async (c, next) => {
489
509
  await next();
490
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
+
491
516
  const preserveRouteSecurityHeaders = c.req.path.startsWith("/hosted/");
492
517
  const setSecurityHeader = (name: string, value: string) => {
493
518
  if (preserveRouteSecurityHeaders && c.res.headers.has(name)) {
@@ -535,9 +560,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
535
560
  setSecurityHeader("X-Content-Type-Options", "nosniff");
536
561
  setSecurityHeader("X-Frame-Options", "DENY");
537
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.
538
565
  setSecurityHeader(
539
566
  "Permissions-Policy",
540
- "camera=(), microphone=(), geolocation=()",
567
+ "camera=(self), microphone=(self), geolocation=()",
541
568
  );
542
569
  // HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
543
570
  // (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
@@ -564,14 +591,31 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
564
591
  const recoveryDue = now - lastNotificationPushRecoverySweep >= 60_000;
565
592
  if (mutating || recoveryDue) {
566
593
  if (recoveryDue) lastNotificationPushRecoverySweep = now;
594
+ const sweep = (async () => {
595
+ try {
596
+ await enqueuePendingNotificationPushJobs(c.env);
597
+ } catch (error) {
598
+ log.error("Failed to enqueue notification push outbox", {
599
+ event: "notification.push.enqueue_failed",
600
+ error,
601
+ });
602
+ }
603
+ })();
604
+ // Prefer to run the sweep AFTER the response is sent (waitUntil) so its
605
+ // 2-4 D1 round-trips never add latency to the request. `executionCtx`
606
+ // throws when no runtime context exists (tests / plain fetch); fall back
607
+ // to awaiting inline there.
608
+ let deferred = false;
567
609
  try {
568
- await enqueuePendingNotificationPushJobs(c.env);
569
- } catch (error) {
570
- log.error("Failed to enqueue notification push outbox", {
571
- event: "notification.push.enqueue_failed",
572
- error,
573
- });
610
+ const ctx = c.executionCtx;
611
+ if (ctx && typeof ctx.waitUntil === "function") {
612
+ ctx.waitUntil(sweep);
613
+ deferred = true;
614
+ }
615
+ } catch {
616
+ // No execution context; await inline below.
574
617
  }
618
+ if (!deferred) await sweep;
575
619
  }
576
620
  });
577
621
 
@@ -658,6 +702,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
658
702
  app.use(pattern, rateLimit(RateLimitConfigs.inbox));
659
703
  }
660
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
+
661
710
  // Federation discovery endpoints are unauthenticated and can be probed by
662
711
  // any remote actor. Throttle them per-IP to mitigate enumeration / DoS.
663
712
  app.use(
@@ -731,6 +780,8 @@ function mountCoreRoutes(app: YurucommuApp): void {
731
780
  app.route("/api/moderation", moderationRoutes);
732
781
  app.route("/api/apps", appsApiRoutes);
733
782
  app.route("/hosted", appsServeRoutes);
783
+ // Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
784
+ app.route("/", rtcRoutes);
734
785
  app.route("/", activitypubRoutes);
735
786
  }
736
787
 
@@ -898,6 +949,10 @@ type WorkerBindings = EnvVars & {
898
949
  ASSETS?: Fetcher;
899
950
  DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
900
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;
901
956
  };
902
957
 
903
958
  export default {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared attachment bounds + validation.
3
+ *
4
+ * Owner of the attachment payload limits used by every write surface (post
5
+ * create, DM messages, community chat) and by inbound-federation bounding.
6
+ * Route files must import from here instead of copy-pasting the checks or
7
+ * reaching into another route's transformers module.
8
+ */
9
+
10
+ // Bound the attachments payload. An attachment is an open-ended record, so cap
11
+ // both the COUNT and the serialized SIZE — the size cap bounds row/federated-doc
12
+ // bloat regardless of internal shape (key count / field length). 16 KiB is ample
13
+ // for MAX_ATTACHMENTS media descriptors with alt text + blurhash.
14
+ export const MAX_ATTACHMENTS = 8;
15
+ export const MAX_ATTACHMENTS_JSON_LENGTH = 16 * 1024;
16
+
17
+ /** Drop an oversized inbound attachments blob to "[]" rather than store it. */
18
+ export function boundAttachmentsJson(json: string): string {
19
+ return json.length > MAX_ATTACHMENTS_JSON_LENGTH ? "[]" : json;
20
+ }
21
+
22
+ export type ChatAttachment = Record<string, unknown>;
23
+
24
+ export type ChatAttachmentsResult =
25
+ | { readonly ok: true; readonly attachments: ChatAttachment[] }
26
+ | { readonly ok: false; readonly error: string };
27
+
28
+ /**
29
+ * Validate a chat message's attachments array (mirrors the post-create bounds:
30
+ * records only, capped count + serialized size). Returns the validated array
31
+ * ([] when absent) or an error message. Used by both the DM and community-chat
32
+ * send routes so the two cannot drift.
33
+ */
34
+ export function validateChatAttachments(raw: unknown): ChatAttachmentsResult {
35
+ if (raw === undefined || raw === null) return { ok: true, attachments: [] };
36
+ if (!Array.isArray(raw)) {
37
+ return { ok: false, error: "attachments must be an array" };
38
+ }
39
+ if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
40
+ return { ok: false, error: "attachments must be objects" };
41
+ }
42
+ if (raw.length > MAX_ATTACHMENTS) {
43
+ return {
44
+ ok: false,
45
+ error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
46
+ };
47
+ }
48
+ if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
49
+ return { ok: false, error: "attachments payload too large" };
50
+ }
51
+ return { ok: true, attachments: raw as ChatAttachment[] };
52
+ }
@@ -24,6 +24,7 @@ import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
24
24
  import {
25
25
  enqueuePendingNotificationPushJobs,
26
26
  processNotificationPushJob,
27
+ recoverDeadLetteredNotificationPushJob,
27
28
  } from "../notification-push.ts";
28
29
 
29
30
  const log = logger.child({ component: "delivery.queue" });
@@ -539,10 +540,21 @@ export async function handleDeliveryDlqBatch(
539
540
  for (const message of batch.messages) {
540
541
  const body = message.body;
541
542
  if (!isDeliveryDlqMessageV1(body)) {
543
+ // Not an app-built `dlq` message. Cloudflare Queues also delivers here
544
+ // the RAW original body of any MAIN-queue message that exhausted its
545
+ // retries (automatic dead-lettering). Those must NOT be silently acked as
546
+ // "invalid": a lost fanout/resolve drops local notifications or delivery
547
+ // planning, and a lost notification_push strands its durable outbox row.
548
+ if (isDeliveryQueueMessageV1(body)) {
549
+ await handleAutoDeadLetteredMessage(env, body);
550
+ message.ack();
551
+ continue;
552
+ }
542
553
  log.warn("Invalid DLQ message format, skipping", {
543
554
  event: "delivery.dlq.invalid_message",
544
555
  bodyPreview: JSON.stringify(body).slice(0, 200),
545
556
  });
557
+ emitMetric("delivery.dlq.invalid_message", 1, {});
546
558
  message.ack();
547
559
  continue;
548
560
  }
@@ -592,3 +604,46 @@ export async function handleDeliveryDlqBatch(
592
604
  message.ack();
593
605
  }
594
606
  }
607
+
608
+ /**
609
+ * Recover / account for a MAIN-queue message that Cloudflare auto-dead-lettered
610
+ * (retries exhausted with the raw body). `notification_push` rows are durable,
611
+ * so reset the job to retry through the outbox instead of stranding it;
612
+ * everything else is logged with an alerting metric rather than swallowed.
613
+ */
614
+ async function handleAutoDeadLetteredMessage(
615
+ env: Env,
616
+ body: DeliveryQueueMessageV1,
617
+ ): Promise<void> {
618
+ if (body.type === "notification_push") {
619
+ try {
620
+ const recovered = await recoverDeadLetteredNotificationPushJob(
621
+ env.DB_INSTANCE,
622
+ body.jobId,
623
+ );
624
+ log.error("notification_push dead-lettered; reset durable outbox row", {
625
+ event: "delivery.dlq.notification_push_recovered",
626
+ jobId: body.jobId,
627
+ recovered,
628
+ });
629
+ emitMetric("delivery.dlq.notification_push_recovered", 1, {});
630
+ } catch (error) {
631
+ log.error("Failed to recover dead-lettered notification_push", {
632
+ event: "delivery.dlq.notification_push_recover_failed",
633
+ jobId: body.jobId,
634
+ error,
635
+ });
636
+ emitMetric("delivery.dlq.notification_push_recover_failed", 1, {});
637
+ }
638
+ return;
639
+ }
640
+
641
+ // fanout_*/resolve_actor/deliver_endpoint/reconcile_job: no durable ledger to
642
+ // rewind here, but make the loss explicit (alertable) rather than a silent
643
+ // "invalid message" ack.
644
+ log.error("Delivery message auto-dead-lettered; dropping", {
645
+ event: "delivery.dlq.auto_dead_lettered",
646
+ messageType: body.type,
647
+ });
648
+ emitMetric("delivery.dlq.auto_dead_lettered", 1, { message_type: body.type });
649
+ }