@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.
Files changed (116) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +21 -95
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/bun.ts +1585 -99
  105. package/src/backend/runtime/call-signaling-do.ts +35 -4
  106. package/src/backend/runtime/cloudflare.ts +1 -1
  107. package/src/backend/runtime/managed-runtime.ts +1 -1
  108. package/src/backend/runtime/one-time-ticket.ts +116 -0
  109. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  110. package/src/backend/runtime/signaling-hub.ts +36 -3
  111. package/src/backend/runtime/types.ts +1 -1
  112. package/src/backend/server.ts +95 -80
  113. package/src/db/d1-write.ts +67 -43
  114. package/src/db/schema/federation.ts +42 -1
  115. package/src/db/schema/messaging.ts +110 -2
  116. 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, url);
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, url: URL): Promise<Response> {
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 actor =
89
- request.headers.get("X-Call-Actor") ?? url.searchParams.get("actor");
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
- /** Outstanding one-time tickets per user (multiple tabs may mint at once). */
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
- const hash = await sha256Hex(ticket);
157
- const key = `${TICKET_PREFIX}${hash}`;
158
- const stored = await this.state.storage.get<StoredTicket>(key);
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(request: Request, actorApId: string): Promise<Response>;
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(request: Request, actorApId: string): Promise<Response> {
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(_request: Request, _actorApId: string): Promise<Response> {
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>;
@@ -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 { buildDeliverEndpointMessage } from "./lib/delivery/queue.ts";
33
- import { deliveryQueue, getDbSQLite } from "../db/index.ts";
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
- const ENV_PASSTHROUGH_KEYS = [
82
- "AUTH_PASSWORD_HASH",
83
- "ENCRYPTION_KEY",
84
- "GOOGLE_CLIENT_ID",
85
- "GOOGLE_CLIENT_SECRET",
86
- "X_CLIENT_ID",
87
- "X_CLIENT_SECRET",
88
- "OIDC_ISSUER_URL",
89
- "OIDC_CLIENT_ID",
90
- "OIDC_CLIENT_SECRET",
91
- "OAUTH_ISSUER_URL",
92
- "TAKOSUMI_ACCOUNTS_ISSUER_URL",
93
- "TAKOSUMI_ACCOUNTS_CLIENT_ID",
94
- "TAKOSUMI_ACCOUNTS_CLIENT_SECRET",
95
- "TAKOS_URL",
96
- "AUTH_MODE",
97
- "CSRF_ALLOWED_ORIGINS",
98
- "ENABLE_TAKOS_TOOLS",
99
- "DELIVERY_SHADOW_PROBE_HOSTS",
100
- "DELIVERY_SHADOW_PROBE_SAMPLE_RATE",
101
- "DELIVERY_QUEUE_NAME",
102
- "DELIVERY_DLQ_NAME",
103
- "YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES",
104
- "YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE",
105
- "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS",
106
- "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL",
107
- "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN",
108
- "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS",
109
- "YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK",
110
- "YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY",
111
- ] as const;
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
- const queue = env.DELIVERY_QUEUE;
281
- if (!queue) return 0;
282
-
283
- const db = env.DB_INSTANCE;
284
- const staleBefore = new Date(
285
- Date.now() - RECONCILE_STALE_PROCESSING_MS,
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 requeued = await reconcileLocalDeliveryQueue(env);
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: Record<string, string | undefined> = {};
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 = {