@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.
- package/migrations/0019_notification_push_delivery.sql +10 -7
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +6 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/api/browser-push.ts +11 -0
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +2 -0
- package/packages/api/src/lib/rtc-client.ts +542 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +16 -4
- package/src/backend/index.ts +65 -10
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +55 -0
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +159 -146
- package/src/backend/lib/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- package/src/backend/lib/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/public.ts +3 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +131 -3
- package/src/backend/routes/communities/messages.ts +16 -33
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +5 -39
- package/src/backend/routes/notifications.ts +27 -70
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/index.ts +11 -10
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
- 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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 {
|
package/src/backend/index.ts
CHANGED
|
@@ -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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
}
|