@takosjp/yurucommu-core 3.4.3 → 3.4.5
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/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +21 -95
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/bun.ts +1585 -99
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/cloudflare.ts +1 -1
- package/src/backend/runtime/managed-runtime.ts +1 -1
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/runtime/types.ts +1 -1
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
isTerminalCallState,
|
|
27
27
|
parseRtcSignalEnvelope,
|
|
28
28
|
} from "../../../packages/api/src/types/call.ts";
|
|
29
|
+
import { consumeOneTimeTicket, mintOneTimeTicket } from "./one-time-ticket.ts";
|
|
29
30
|
|
|
30
31
|
// --- Minimal Cloudflare DO + Hibernatable WebSocket surface ----------------
|
|
31
32
|
interface DoWebSocket {
|
|
@@ -57,6 +58,7 @@ type CallDoEnv = EnvVars & {
|
|
|
57
58
|
const ALARM_INTERVAL_MS = 15_000;
|
|
58
59
|
const ACTOR_KEY = "actor";
|
|
59
60
|
const CALL_PREFIX = "call:";
|
|
61
|
+
const TICKET_PREFIX = "ticket:";
|
|
60
62
|
|
|
61
63
|
export class CallSignalingDurableObject {
|
|
62
64
|
private hub: CallHub | null = null;
|
|
@@ -73,20 +75,36 @@ export class CallSignalingDurableObject {
|
|
|
73
75
|
async fetch(request: Request): Promise<Response> {
|
|
74
76
|
const url = new URL(request.url);
|
|
75
77
|
if (url.pathname === "/_ws") {
|
|
76
|
-
return this.handleUpgrade(request
|
|
78
|
+
return this.handleUpgrade(request);
|
|
77
79
|
}
|
|
78
80
|
if (url.pathname === "/_ingest") {
|
|
79
81
|
return this.handleIngest(request);
|
|
80
82
|
}
|
|
83
|
+
if (url.pathname === "/_ticket") {
|
|
84
|
+
return this.handleMintTicket(request);
|
|
85
|
+
}
|
|
81
86
|
return new Response("not found", { status: 404 });
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
private async handleUpgrade(request: Request
|
|
89
|
+
private async handleUpgrade(request: Request): Promise<Response> {
|
|
85
90
|
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
86
91
|
return new Response("expected websocket", { status: 426 });
|
|
87
92
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
93
|
+
const authMode = request.headers.get("X-Call-Auth");
|
|
94
|
+
if (authMode !== "ticket") {
|
|
95
|
+
return new Response("unauthorized", { status: 401 });
|
|
96
|
+
}
|
|
97
|
+
const ticket = request.headers.get("X-Call-Ticket") ?? "";
|
|
98
|
+
if (
|
|
99
|
+
!ticket ||
|
|
100
|
+
!(await consumeOneTimeTicket(this.state.storage, ticket, {
|
|
101
|
+
prefix: TICKET_PREFIX,
|
|
102
|
+
}))
|
|
103
|
+
) {
|
|
104
|
+
return new Response("invalid ticket", { status: 401 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const actor = request.headers.get("X-Call-Actor");
|
|
90
108
|
if (!actor) return new Response("missing actor", { status: 400 });
|
|
91
109
|
await this.setActor(actor);
|
|
92
110
|
|
|
@@ -102,6 +120,19 @@ export class CallSignalingDurableObject {
|
|
|
102
120
|
} as unknown as ResponseInit);
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
private async handleMintTicket(request: Request): Promise<Response> {
|
|
124
|
+
if (request.method !== "POST") {
|
|
125
|
+
return new Response("method not allowed", { status: 405 });
|
|
126
|
+
}
|
|
127
|
+
const actor = request.headers.get("X-Call-Actor");
|
|
128
|
+
if (!actor) return new Response("missing actor", { status: 400 });
|
|
129
|
+
await this.setActor(actor);
|
|
130
|
+
const ticket = await mintOneTimeTicket(this.state.storage, {
|
|
131
|
+
prefix: TICKET_PREFIX,
|
|
132
|
+
});
|
|
133
|
+
return Response.json({ ticket });
|
|
134
|
+
}
|
|
135
|
+
|
|
105
136
|
private async handleIngest(request: Request): Promise<Response> {
|
|
106
137
|
let body: unknown;
|
|
107
138
|
try {
|
|
@@ -38,7 +38,7 @@ class CloudflareStorage implements IObjectStorage {
|
|
|
38
38
|
|
|
39
39
|
async put(
|
|
40
40
|
key: string,
|
|
41
|
-
value: ReadableStream | ArrayBuffer | string,
|
|
41
|
+
value: Blob | ReadableStream | ArrayBuffer | string,
|
|
42
42
|
options?: {
|
|
43
43
|
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
44
44
|
customMetadata?: Record<string, string>;
|
|
@@ -334,7 +334,7 @@ class ManagedRuntimeObjectStorage implements IObjectStorage {
|
|
|
334
334
|
|
|
335
335
|
async put(
|
|
336
336
|
key: string,
|
|
337
|
-
value: ReadableStream | ArrayBuffer | string,
|
|
337
|
+
value: Blob | ReadableStream | ArrayBuffer | string,
|
|
338
338
|
options?: {
|
|
339
339
|
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
340
340
|
customMetadata?: Record<string, string>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small Durable-Object ticket primitive shared by realtime and call sockets.
|
|
3
|
+
*
|
|
4
|
+
* Browser WebSocket constructors cannot attach an Authorization header. The
|
|
5
|
+
* authenticated fetch path therefore mints an opaque, short-lived ticket in
|
|
6
|
+
* the exact per-user DO that will accept the upgrade. Only its SHA-256 hash is
|
|
7
|
+
* stored, and a consume attempt deletes it before returning a verdict.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface OneTimeTicketStorage {
|
|
11
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
12
|
+
put(key: string, value: unknown): Promise<void>;
|
|
13
|
+
delete(key: string): Promise<boolean>;
|
|
14
|
+
list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OneTimeTicketOptions {
|
|
18
|
+
prefix: string;
|
|
19
|
+
ttlMs?: number;
|
|
20
|
+
maxOutstanding?: number;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface StoredTicket {
|
|
25
|
+
hash: string;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
30
|
+
const DEFAULT_MAX_OUTSTANDING = 8;
|
|
31
|
+
|
|
32
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
33
|
+
const digest = await crypto.subtle.digest(
|
|
34
|
+
"SHA-256",
|
|
35
|
+
new TextEncoder().encode(value),
|
|
36
|
+
);
|
|
37
|
+
return [...new Uint8Array(digest)]
|
|
38
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
39
|
+
.join("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compare strings through fixed-width digests using the Workers primitive. */
|
|
43
|
+
async function timingSafeEqualStrings(a: string, b: string): Promise<boolean> {
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const [aDigest, bDigest] = await Promise.all([
|
|
46
|
+
crypto.subtle.digest("SHA-256", encoder.encode(a)),
|
|
47
|
+
crypto.subtle.digest("SHA-256", encoder.encode(b)),
|
|
48
|
+
]);
|
|
49
|
+
const subtle = crypto.subtle as SubtleCrypto & {
|
|
50
|
+
timingSafeEqual?: (left: BufferSource, right: BufferSource) => boolean;
|
|
51
|
+
};
|
|
52
|
+
if (typeof subtle.timingSafeEqual === "function") {
|
|
53
|
+
return subtle.timingSafeEqual(aDigest, bDigest);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Bun/self-host portability fallback. Both values are SHA-256 digests, so
|
|
57
|
+
// their length is fixed and the loop never short-circuits on content.
|
|
58
|
+
const left = new Uint8Array(aDigest);
|
|
59
|
+
const right = new Uint8Array(bDigest);
|
|
60
|
+
let diff = 0;
|
|
61
|
+
for (let index = 0; index < left.length; index++) {
|
|
62
|
+
diff |= left[index]! ^ right[index]!;
|
|
63
|
+
}
|
|
64
|
+
return diff === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function mintOneTimeTicket(
|
|
68
|
+
storage: OneTimeTicketStorage,
|
|
69
|
+
options: OneTimeTicketOptions,
|
|
70
|
+
): Promise<string> {
|
|
71
|
+
const ticket =
|
|
72
|
+
crypto.randomUUID().replaceAll("-", "") +
|
|
73
|
+
crypto.randomUUID().replaceAll("-", "");
|
|
74
|
+
const hash = await sha256Hex(ticket);
|
|
75
|
+
const now = (options.now ?? Date.now)();
|
|
76
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
77
|
+
const maxOutstanding = options.maxOutstanding ?? DEFAULT_MAX_OUTSTANDING;
|
|
78
|
+
|
|
79
|
+
const stored = await storage.list<StoredTicket>({ prefix: options.prefix });
|
|
80
|
+
const live = [...stored.entries()]
|
|
81
|
+
.filter(([, value]) => value.expiresAt > now)
|
|
82
|
+
.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
83
|
+
|
|
84
|
+
const liveKeys = new Set(live.map(([key]) => key));
|
|
85
|
+
for (const [key] of stored) {
|
|
86
|
+
if (!liveKeys.has(key)) await storage.delete(key);
|
|
87
|
+
}
|
|
88
|
+
while (live.length >= maxOutstanding) {
|
|
89
|
+
const [oldestKey] = live.shift()!;
|
|
90
|
+
await storage.delete(oldestKey);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await storage.put(`${options.prefix}${hash}`, {
|
|
94
|
+
hash,
|
|
95
|
+
expiresAt: now + ttlMs,
|
|
96
|
+
} satisfies StoredTicket);
|
|
97
|
+
return ticket;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function consumeOneTimeTicket(
|
|
101
|
+
storage: OneTimeTicketStorage,
|
|
102
|
+
ticket: string,
|
|
103
|
+
options: OneTimeTicketOptions,
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
const hash = await sha256Hex(ticket);
|
|
106
|
+
const key = `${options.prefix}${hash}`;
|
|
107
|
+
const stored = await storage.get<StoredTicket>(key);
|
|
108
|
+
if (!stored) return false;
|
|
109
|
+
|
|
110
|
+
// Delete before validating so expired or malformed replay attempts also
|
|
111
|
+
// consume the only matching record.
|
|
112
|
+
await storage.delete(key);
|
|
113
|
+
const now = (options.now ?? Date.now)();
|
|
114
|
+
if (stored.expiresAt <= now) return false;
|
|
115
|
+
return timingSafeEqualStrings(stored.hash, hash);
|
|
116
|
+
}
|
|
@@ -60,40 +60,13 @@ const EVENT_PREFIX = "evt:";
|
|
|
60
60
|
/** Ring buffer size: how many events a reconnect can replay before `resync`. */
|
|
61
61
|
const EVENT_BUFFER_SIZE = 200;
|
|
62
62
|
const TICKET_PREFIX = "ticket:";
|
|
63
|
-
|
|
64
|
-
const MAX_OUTSTANDING_TICKETS = 8;
|
|
65
|
-
const TICKET_TTL_MS = 60_000;
|
|
66
|
-
|
|
67
|
-
interface StoredTicket {
|
|
68
|
-
hash: string;
|
|
69
|
-
expiresAt: number;
|
|
70
|
-
}
|
|
63
|
+
import { consumeOneTimeTicket, mintOneTimeTicket } from "./one-time-ticket.ts";
|
|
71
64
|
|
|
72
65
|
function eventKey(seq: number): string {
|
|
73
66
|
// Fixed-width key so storage.list({prefix}) returns events in seq order.
|
|
74
67
|
return `${EVENT_PREFIX}${String(seq).padStart(12, "0")}`;
|
|
75
68
|
}
|
|
76
69
|
|
|
77
|
-
async function sha256Hex(value: string): Promise<string> {
|
|
78
|
-
const digest = await crypto.subtle.digest(
|
|
79
|
-
"SHA-256",
|
|
80
|
-
new TextEncoder().encode(value),
|
|
81
|
-
);
|
|
82
|
-
return [...new Uint8Array(digest)]
|
|
83
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
84
|
-
.join("");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Constant-time hex-string comparison (both inputs are fixed-width hashes). */
|
|
88
|
-
function timingSafeEqualHex(a: string, b: string): boolean {
|
|
89
|
-
if (a.length !== b.length) return false;
|
|
90
|
-
let diff = 0;
|
|
91
|
-
for (let i = 0; i < a.length; i++) {
|
|
92
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
93
|
-
}
|
|
94
|
-
return diff === 0;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
70
|
export class RealtimeStreamDO {
|
|
98
71
|
private seqCache: number | null = null;
|
|
99
72
|
|
|
@@ -122,45 +95,16 @@ export class RealtimeStreamDO {
|
|
|
122
95
|
if (request.method !== "POST") {
|
|
123
96
|
return new Response("method not allowed", { status: 405 });
|
|
124
97
|
}
|
|
125
|
-
const ticket =
|
|
126
|
-
crypto.randomUUID().replaceAll("-", "") +
|
|
127
|
-
crypto.randomUUID().replaceAll("-", "");
|
|
128
|
-
const hash = await sha256Hex(ticket);
|
|
129
|
-
const now = Date.now();
|
|
130
|
-
|
|
131
|
-
const stored = await this.state.storage.list<StoredTicket>({
|
|
98
|
+
const ticket = await mintOneTimeTicket(this.state.storage, {
|
|
132
99
|
prefix: TICKET_PREFIX,
|
|
133
100
|
});
|
|
134
|
-
// Drop expired tickets; keep the newest few so parallel tabs still work.
|
|
135
|
-
const live = [...stored.entries()]
|
|
136
|
-
.filter(([, t]) => t.expiresAt > now)
|
|
137
|
-
.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
138
|
-
for (const [key] of stored) {
|
|
139
|
-
if (!live.some(([liveKey]) => liveKey === key)) {
|
|
140
|
-
await this.state.storage.delete(key);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
while (live.length >= MAX_OUTSTANDING_TICKETS) {
|
|
144
|
-
const [oldestKey] = live.shift()!;
|
|
145
|
-
await this.state.storage.delete(oldestKey);
|
|
146
|
-
}
|
|
147
|
-
await this.state.storage.put(`${TICKET_PREFIX}${hash}`, {
|
|
148
|
-
hash,
|
|
149
|
-
expiresAt: now + TICKET_TTL_MS,
|
|
150
|
-
} satisfies StoredTicket);
|
|
151
|
-
|
|
152
101
|
return Response.json({ ticket });
|
|
153
102
|
}
|
|
154
103
|
|
|
155
104
|
private async consumeTicket(ticket: string): Promise<boolean> {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!stored) return false;
|
|
160
|
-
// Single-use: consume before validating expiry so a replay always misses.
|
|
161
|
-
await this.state.storage.delete(key);
|
|
162
|
-
if (stored.expiresAt <= Date.now()) return false;
|
|
163
|
-
return timingSafeEqualHex(stored.hash, hash);
|
|
105
|
+
return consumeOneTimeTicket(this.state.storage, ticket, {
|
|
106
|
+
prefix: TICKET_PREFIX,
|
|
107
|
+
});
|
|
164
108
|
}
|
|
165
109
|
|
|
166
110
|
// -------------------------------------------------------------------------
|
|
@@ -23,7 +23,13 @@ const log = logger.child({ component: "rtc.hub" });
|
|
|
23
23
|
|
|
24
24
|
export interface ISignalingHub {
|
|
25
25
|
/** Handle a browser WebSocket upgrade for `actorApId` (returns 101). */
|
|
26
|
-
upgrade(
|
|
26
|
+
upgrade(
|
|
27
|
+
request: Request,
|
|
28
|
+
actorApId: string,
|
|
29
|
+
ticket: string,
|
|
30
|
+
): Promise<Response>;
|
|
31
|
+
/** Mint a one-time short-lived WS ticket inside the user's call DO. */
|
|
32
|
+
mintTicket(actorApId: string): Promise<string | null>;
|
|
27
33
|
/** Push an inbound cross-instance signal to `actorApId`'s live sockets. */
|
|
28
34
|
deliver(actorApId: string, envelope: RtcSignalEnvelopeV1): Promise<void>;
|
|
29
35
|
}
|
|
@@ -38,9 +44,15 @@ class CloudflareSignalingHub implements ISignalingHub {
|
|
|
38
44
|
return this.ns.get(this.ns.idFromName(actorApId));
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
async upgrade(
|
|
47
|
+
async upgrade(
|
|
48
|
+
request: Request,
|
|
49
|
+
actorApId: string,
|
|
50
|
+
ticket: string,
|
|
51
|
+
): Promise<Response> {
|
|
42
52
|
const headers = new Headers(request.headers);
|
|
43
53
|
headers.set("X-Call-Actor", actorApId);
|
|
54
|
+
headers.set("X-Call-Auth", "ticket");
|
|
55
|
+
headers.set("X-Call-Ticket", ticket);
|
|
44
56
|
const forwarded = new Request("https://call-do/_ws", {
|
|
45
57
|
method: "GET",
|
|
46
58
|
headers,
|
|
@@ -50,6 +62,19 @@ class CloudflareSignalingHub implements ISignalingHub {
|
|
|
50
62
|
) as unknown as Promise<Response>;
|
|
51
63
|
}
|
|
52
64
|
|
|
65
|
+
async mintTicket(actorApId: string): Promise<string | null> {
|
|
66
|
+
const response = await this.stub(actorApId).fetch(
|
|
67
|
+
"https://call-do/_ticket",
|
|
68
|
+
{
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "X-Call-Actor": actorApId },
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
if (!response.ok) return null;
|
|
74
|
+
const body = (await response.json()) as { ticket?: unknown };
|
|
75
|
+
return typeof body.ticket === "string" ? body.ticket : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
async deliver(
|
|
54
79
|
actorApId: string,
|
|
55
80
|
envelope: RtcSignalEnvelopeV1,
|
|
@@ -143,7 +168,11 @@ class LocalSignalingHub implements ISignalingHub {
|
|
|
143
168
|
);
|
|
144
169
|
}
|
|
145
170
|
|
|
146
|
-
async upgrade(
|
|
171
|
+
async upgrade(
|
|
172
|
+
_request: Request,
|
|
173
|
+
_actorApId: string,
|
|
174
|
+
_ticket: string,
|
|
175
|
+
): Promise<Response> {
|
|
147
176
|
// The Bun runtime upgrades WebSockets at the server boundary (server.upgrade)
|
|
148
177
|
// and drives attach()/message()/detach() directly, so this Hono-level path is
|
|
149
178
|
// never used there. Reaching it means a runtime without Durable Objects and
|
|
@@ -157,6 +186,10 @@ class LocalSignalingHub implements ISignalingHub {
|
|
|
157
186
|
);
|
|
158
187
|
}
|
|
159
188
|
|
|
189
|
+
async mintTicket(): Promise<string | null> {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
160
193
|
async deliver(
|
|
161
194
|
actorApId: string,
|
|
162
195
|
envelope: RtcSignalEnvelopeV1,
|
|
@@ -115,7 +115,7 @@ export interface ListObjectsResult {
|
|
|
115
115
|
export interface IObjectStorage {
|
|
116
116
|
put(
|
|
117
117
|
key: string,
|
|
118
|
-
value: ReadableStream | ArrayBuffer | string,
|
|
118
|
+
value: Blob | ReadableStream | ArrayBuffer | string,
|
|
119
119
|
options?: {
|
|
120
120
|
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
121
121
|
customMetadata?: Record<string, string>;
|
package/src/backend/server.ts
CHANGED
|
@@ -21,16 +21,20 @@
|
|
|
21
21
|
|
|
22
22
|
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
23
23
|
import process from "node:process";
|
|
24
|
-
import { and, inArray, lt, or } from "drizzle-orm";
|
|
25
24
|
import { BunAssets, BunDatabase, BunStorage } from "./runtime/bun.ts";
|
|
26
25
|
import { MemoryKV } from "./runtime/memory-kv.ts";
|
|
27
|
-
import type { Env } from "./types.ts";
|
|
26
|
+
import type { Env, EnvVars } from "./types.ts";
|
|
28
27
|
import type {
|
|
29
28
|
DeliveryDlqMessageV1,
|
|
30
29
|
DeliveryQueueMessageV1,
|
|
31
30
|
} from "./lib/delivery/types.ts";
|
|
32
|
-
import {
|
|
33
|
-
|
|
31
|
+
import {
|
|
32
|
+
buildDeliverEndpointMessage,
|
|
33
|
+
enqueuePendingDeliveryEndpointJobs,
|
|
34
|
+
} from "./lib/delivery/queue.ts";
|
|
35
|
+
import { enqueuePendingDeliveryResolutionJobs } from "./lib/delivery/resolution-outbox.ts";
|
|
36
|
+
import { enqueuePendingDeliveryFanoutJobs } from "./lib/delivery/fanout-outbox.ts";
|
|
37
|
+
import { getDbSQLite } from "../db/index.ts";
|
|
34
38
|
import { logger } from "./lib/logger.ts";
|
|
35
39
|
import type {
|
|
36
40
|
IQueueBatch,
|
|
@@ -78,37 +82,75 @@ const APP_URL = process.env.APP_URL ?? `http://localhost:${PORT}`;
|
|
|
78
82
|
// Create Cloudflare-compatible environment from the local runtime
|
|
79
83
|
// ---------------------------------------------------------------------------
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
85
|
+
type LocalRuntimeEnvKey = Exclude<keyof EnvVars, "APP_URL">;
|
|
86
|
+
|
|
87
|
+
// Keep the Bun self-host adapter wire-equivalent to Worker bindings. Using a
|
|
88
|
+
// complete Record makes TypeScript fail whenever EnvVars gains a new runtime
|
|
89
|
+
// setting without an explicit local-runtime decision, instead of silently
|
|
90
|
+
// dropping authority/security configuration such as OIDC_OWNER_SUB.
|
|
91
|
+
const ENV_PASSTHROUGH_KEY_SET: Record<LocalRuntimeEnvKey, true> = {
|
|
92
|
+
ENABLE_TAKOS_TOOLS: true,
|
|
93
|
+
AUTH_PASSWORD_HASH: true,
|
|
94
|
+
GOOGLE_CLIENT_ID: true,
|
|
95
|
+
GOOGLE_CLIENT_SECRET: true,
|
|
96
|
+
X_CLIENT_ID: true,
|
|
97
|
+
X_CLIENT_SECRET: true,
|
|
98
|
+
OIDC_ISSUER_URL: true,
|
|
99
|
+
OIDC_CLIENT_ID: true,
|
|
100
|
+
OIDC_CLIENT_SECRET: true,
|
|
101
|
+
OAUTH_ISSUER_URL: true,
|
|
102
|
+
TAKOSUMI_ACCOUNTS_ISSUER_URL: true,
|
|
103
|
+
TAKOSUMI_ACCOUNTS_CLIENT_ID: true,
|
|
104
|
+
TAKOSUMI_ACCOUNTS_CLIENT_SECRET: true,
|
|
105
|
+
OIDC_OWNER_SUB: true,
|
|
106
|
+
TAKOSUMI_ACCOUNTS_OWNER_SUB: true,
|
|
107
|
+
ALLOW_UNPINNED_OWNER_CLAIM: true,
|
|
108
|
+
OIDC_ALLOWED_SUBS: true,
|
|
109
|
+
TAKOS_URL: true,
|
|
110
|
+
AUTH_MODE: true,
|
|
111
|
+
ENCRYPTION_KEY: true,
|
|
112
|
+
YURUCOMMU_SESSION_HASH_SALT: true,
|
|
113
|
+
DELIVERY_SHADOW_PROBE_HOSTS: true,
|
|
114
|
+
DELIVERY_SHADOW_PROBE_SAMPLE_RATE: true,
|
|
115
|
+
DELIVERY_QUEUE_NAME: true,
|
|
116
|
+
DELIVERY_DLQ_NAME: true,
|
|
117
|
+
YURUCOMMU_STRICT_READINESS: true,
|
|
118
|
+
YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES: true,
|
|
119
|
+
YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE: true,
|
|
120
|
+
YURUCOMMU_SOFTWARE_VERSION: true,
|
|
121
|
+
YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS: true,
|
|
122
|
+
YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL: true,
|
|
123
|
+
YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN: true,
|
|
124
|
+
YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS: true,
|
|
125
|
+
YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK: true,
|
|
126
|
+
YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY: true,
|
|
127
|
+
CSRF_ALLOWED_ORIGINS: true,
|
|
128
|
+
YURUCOMMU_RTC_ICE_SERVERS: true,
|
|
129
|
+
YURUCOMMU_RTC_TURN_URIS: true,
|
|
130
|
+
YURUCOMMU_RTC_TURN_SECRET: true,
|
|
131
|
+
YURUCOMMU_RTC_TURN_TTL: true,
|
|
132
|
+
YURUCOMMU_RTC_SFU_ADAPTER: true,
|
|
133
|
+
YURUCOMMU_RTC_SFU_URL: true,
|
|
134
|
+
YURUCOMMU_RTC_SFU_TOKEN: true,
|
|
135
|
+
YURUCOMMU_RTC_SFU_APP_ID: true,
|
|
136
|
+
YURUCOMMU_RTC_SFU_APP_SECRET: true,
|
|
137
|
+
TAKOS_TRUST_PROXY: true,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const ENV_PASSTHROUGH_KEYS = Object.keys(
|
|
141
|
+
ENV_PASSTHROUGH_KEY_SET,
|
|
142
|
+
) as LocalRuntimeEnvKey[];
|
|
143
|
+
|
|
144
|
+
export function buildLocalRuntimeEnvPassthrough(
|
|
145
|
+
source: Readonly<Record<string, string | undefined>>,
|
|
146
|
+
): Partial<Record<LocalRuntimeEnvKey, string | undefined>> {
|
|
147
|
+
const passthrough: Partial<Record<LocalRuntimeEnvKey, string | undefined>> =
|
|
148
|
+
{};
|
|
149
|
+
for (const key of ENV_PASSTHROUGH_KEYS) {
|
|
150
|
+
passthrough[key] = source[key];
|
|
151
|
+
}
|
|
152
|
+
return passthrough;
|
|
153
|
+
}
|
|
112
154
|
|
|
113
155
|
function isTruthyEnv(value: string | undefined): boolean {
|
|
114
156
|
if (!value) return false;
|
|
@@ -255,21 +297,9 @@ function attachLocalDeliveryQueues(env: LocalServerEnv): void {
|
|
|
255
297
|
// the platform persists in-flight messages and re-delivers them itself, so the
|
|
256
298
|
// sweep must not run there.
|
|
257
299
|
|
|
258
|
-
/** Statuses that still represent work the local queue must (re)drive. */
|
|
259
|
-
const RECONCILE_PENDING_STATUSES = ["pending", "retry_wait"] as const;
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Matches queue-delivery.ts STALE_PROCESSING_MS: a row marked `processing`
|
|
263
|
-
* older than this lost its owning worker and is safe to re-enqueue.
|
|
264
|
-
*/
|
|
265
|
-
const RECONCILE_STALE_PROCESSING_MS = 2 * 60 * 1000;
|
|
266
|
-
|
|
267
300
|
/** How often the periodic reconciliation sweep runs. */
|
|
268
301
|
const RECONCILE_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
269
302
|
|
|
270
|
-
/** How many rows to re-enqueue per sweep pass. */
|
|
271
|
-
const RECONCILE_SWEEP_BATCH = 500;
|
|
272
|
-
|
|
273
303
|
/**
|
|
274
304
|
* Select non-terminal delivery_queue rows and re-enqueue them onto the local
|
|
275
305
|
* delivery queue. Returns the number of rows re-enqueued.
|
|
@@ -277,34 +307,12 @@ const RECONCILE_SWEEP_BATCH = 500;
|
|
|
277
307
|
export async function reconcileLocalDeliveryQueue(
|
|
278
308
|
env: LocalServerEnv,
|
|
279
309
|
): Promise<number> {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
).toISOString();
|
|
287
|
-
|
|
288
|
-
const rows = await db
|
|
289
|
-
.select({ id: deliveryQueue.id })
|
|
290
|
-
.from(deliveryQueue)
|
|
291
|
-
.where(
|
|
292
|
-
or(
|
|
293
|
-
inArray(deliveryQueue.status, [...RECONCILE_PENDING_STATUSES]),
|
|
294
|
-
and(
|
|
295
|
-
inArray(deliveryQueue.status, ["processing"]),
|
|
296
|
-
lt(deliveryQueue.processingStartedAt, staleBefore),
|
|
297
|
-
),
|
|
298
|
-
),
|
|
299
|
-
)
|
|
300
|
-
.limit(RECONCILE_SWEEP_BATCH);
|
|
301
|
-
|
|
302
|
-
if (rows.length === 0) return 0;
|
|
303
|
-
|
|
304
|
-
for (const row of rows) {
|
|
305
|
-
await queue.send(buildDeliverEndpointMessage(row.id));
|
|
306
|
-
}
|
|
307
|
-
return rows.length;
|
|
310
|
+
return await enqueuePendingDeliveryEndpointJobs(env, new Date(), {
|
|
311
|
+
// The local queue is process memory. Recreate even future retry timers now;
|
|
312
|
+
// processDeliverEndpoint will defer them to their stored nextAttemptAt.
|
|
313
|
+
includeDeferred: true,
|
|
314
|
+
includeFreshPending: true,
|
|
315
|
+
});
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
/**
|
|
@@ -315,12 +323,22 @@ export async function reconcileLocalDeliveryQueue(
|
|
|
315
323
|
function startLocalDeliveryQueueReconciler(env: LocalServerEnv): void {
|
|
316
324
|
const runSweep = async (trigger: "startup" | "interval") => {
|
|
317
325
|
try {
|
|
318
|
-
const
|
|
326
|
+
const fanoutJobs = await enqueuePendingDeliveryFanoutJobs(env, {
|
|
327
|
+
// The local Queue is process memory. At startup, accepted-but-not-yet-
|
|
328
|
+
// completed wakeups disappeared with the previous process.
|
|
329
|
+
includePublished: trigger === "startup",
|
|
330
|
+
});
|
|
331
|
+
const endpointJobs = await reconcileLocalDeliveryQueue(env);
|
|
332
|
+
const resolutionJobs = await enqueuePendingDeliveryResolutionJobs(env);
|
|
333
|
+
const requeued = fanoutJobs + endpointJobs + resolutionJobs;
|
|
319
334
|
if (requeued > 0) {
|
|
320
335
|
log.info("Reconciled local delivery queue", {
|
|
321
336
|
event: "server.local_delivery_queue.reconciled",
|
|
322
337
|
trigger,
|
|
323
338
|
requeued,
|
|
339
|
+
fanoutJobs,
|
|
340
|
+
endpointJobs,
|
|
341
|
+
resolutionJobs,
|
|
324
342
|
});
|
|
325
343
|
}
|
|
326
344
|
} catch (error) {
|
|
@@ -351,10 +369,7 @@ async function createLocalServerEnv(config: {
|
|
|
351
369
|
const assets = BunAssets.create(config.assetsPath);
|
|
352
370
|
const media = await BunStorage.create(config.storagePath);
|
|
353
371
|
|
|
354
|
-
const passthrough
|
|
355
|
-
for (const key of ENV_PASSTHROUGH_KEYS) {
|
|
356
|
-
passthrough[key] = process.env[key];
|
|
357
|
-
}
|
|
372
|
+
const passthrough = buildLocalRuntimeEnvPassthrough(process.env);
|
|
358
373
|
|
|
359
374
|
const dbInstance = await getDbSQLite(config.databasePath);
|
|
360
375
|
const env: LocalServerEnv = {
|