@takosjp/yurucommu-core 3.4.0 → 3.4.3

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 (39) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/lib/api/notifications.ts +1 -0
  5. package/packages/api/src/lib/api/posts.ts +1 -0
  6. package/src/backend/index.ts +62 -12
  7. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  8. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  9. package/src/backend/lib/delivery/queue.ts +13 -9
  10. package/src/backend/lib/delivery/types.ts +12 -0
  11. package/src/backend/lib/notification-push.ts +2 -2
  12. package/src/backend/lib/oauth-providers.ts +9 -0
  13. package/src/backend/lib/strip-image-metadata.ts +50 -30
  14. package/src/backend/middleware/bearer-auth.ts +24 -9
  15. package/src/backend/public.ts +38 -1
  16. package/src/backend/retention.ts +78 -0
  17. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  18. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  19. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  20. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  21. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  22. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  23. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  24. package/src/backend/routes/activitypub/inbox.ts +410 -205
  25. package/src/backend/routes/activitypub/outbox.ts +0 -0
  26. package/src/backend/routes/actors.ts +5 -5
  27. package/src/backend/routes/auth.ts +2 -1
  28. package/src/backend/routes/posts/post-helpers.ts +42 -23
  29. package/src/backend/routes/stories/routes.ts +5 -7
  30. package/src/backend/runtime/cloudflare.ts +63 -2
  31. package/src/backend/runtime/managed-relational.ts +197 -0
  32. package/src/backend/runtime/managed-runtime.ts +631 -0
  33. package/src/backend/runtime/queue.ts +40 -0
  34. package/src/backend/server.ts +15 -18
  35. package/src/backend/types.ts +9 -2
  36. package/src/db/d1-write.ts +270 -0
  37. package/src/db/index.ts +17 -0
  38. package/src/db/schema/federation.ts +19 -0
  39. package/src/db/schema/index.ts +1 -0
@@ -0,0 +1,17 @@
1
+ -- Fence concurrent/retried ActivityPub inbox dispatches.
2
+ --
3
+ -- `activities.processed = 0` says an activity is retryable, but it does not say
4
+ -- whether another Worker is actively dispatching it. Keeping the lease in a
5
+ -- separate table preserves the public activities ledger shape and lets a
6
+ -- crashed owner expire without allowing an old owner to commit over a newer
7
+ -- retry.
8
+
9
+ CREATE TABLE IF NOT EXISTS inbound_activity_claims (
10
+ activity_ap_id TEXT PRIMARY KEY,
11
+ processing_token TEXT,
12
+ lease_expires_at TEXT,
13
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS inbound_activity_claims_lease_idx
17
+ ON inbound_activity_claims(lease_expires_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.4.0",
3
+ "version": "3.4.3",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -59,7 +59,7 @@
59
59
  "start": "bun src/backend/server.ts",
60
60
  "dev": "bun src/backend/server.ts",
61
61
  "dev:server": "bun src/backend/server.ts",
62
- "check": "tsc --noEmit && bun run check:no-opentofu-artifacts",
62
+ "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run test",
63
63
  "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
64
64
  "test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
65
65
  "test:backend": "bun test src/backend/",
@@ -82,6 +82,7 @@
82
82
  "dependencies": {
83
83
  "@aws-sdk/client-s3": "^3.971.0",
84
84
  "@libsql/client": "^0.17.0",
85
+ "@takosjp/takosumi-contract": "^1.0.0",
85
86
  "better-sqlite3": "^12.4.1",
86
87
  "drizzle-orm": "^0.45.1",
87
88
  "fdir": "^6.5.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.4.0",
3
+ "version": "3.4.3",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -39,6 +39,7 @@ export async function fetchNotifications(options?: {
39
39
 
40
40
  export async function fetchUnreadCount(): Promise<number> {
41
41
  const res = await apiFetch("/api/notifications/unread/count");
42
+ await assertOk(res, "Failed to load unread notification count");
42
43
  const data = (await res.json()) as { count?: number };
43
44
  return data.count || 0;
44
45
  }
@@ -169,6 +169,7 @@ export async function fetchBookmarks(options?: {
169
169
  if (options?.before) params.set("before", options.before);
170
170
  const query = params.toString() ? `?${params}` : "";
171
171
  const res = await apiFetch(`/api/bookmarks${query}`);
172
+ await assertOk(res, "Failed to load bookmarks");
172
173
  const data = (await res.json()) as PostListResponse & {
173
174
  next_cursor?: string | null;
174
175
  has_more?: boolean;
@@ -4,8 +4,12 @@ import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher
4
4
  import type { Env, EnvVars, Variables } from "./types.ts";
5
5
  import { extractActorFromSession } from "./lib/session-actor.ts";
6
6
  import { isBackendPath } from "./lib/backend-paths.ts";
7
- import { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
8
7
  import {
8
+ wrapCloudflareBindings,
9
+ wrapCloudflareMessageBatch,
10
+ } from "./runtime/cloudflare.ts";
11
+ import {
12
+ getMobileOidcAudience,
9
13
  getOidcClientCredentials,
10
14
  getOidcIssuerUrl,
11
15
  } from "./lib/oauth-providers.ts";
@@ -44,7 +48,16 @@ import { logger } from "./lib/logger.ts";
44
48
 
45
49
  const log = logger.child({ component: "backend.index" });
46
50
  let lastNotificationPushRecoverySweep = 0;
47
- import type { MessageBatch } from "@cloudflare/workers-types";
51
+ import type { IQueueBatch } from "./runtime/queue.ts";
52
+ import type {
53
+ D1Database,
54
+ Fetcher,
55
+ KVNamespace,
56
+ MessageBatch,
57
+ Queue,
58
+ R2Bucket,
59
+ ScheduledController,
60
+ } from "@cloudflare/workers-types";
48
61
  import type {
49
62
  DeliveryDlqMessageV1,
50
63
  DeliveryQueueMessageV1,
@@ -54,6 +67,7 @@ import {
54
67
  handleDeliveryQueueBatch,
55
68
  } from "./lib/delivery/queue.ts";
56
69
  import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
70
+ import { runYurucommuRetention } from "./retention.ts";
57
71
 
58
72
  type YurucommuApp = Hono<{ Bindings: Env; Variables: Variables }>;
59
73
 
@@ -74,6 +88,14 @@ export interface YurucommuBackendPluginV1 {
74
88
  export interface CreateYurucommuBackendAppOptionsV1 {
75
89
  plugins?: YurucommuBackendPluginV1[];
76
90
  discovery?: YurucommuBackendDiscoveryOptionsV1;
91
+ /**
92
+ * Browser capture capabilities required by this product shell. Both default
93
+ * to denied; a product must opt in to each capability it actually uses.
94
+ */
95
+ browserMedia?: {
96
+ camera?: boolean;
97
+ microphone?: boolean;
98
+ };
77
99
  }
78
100
 
79
101
  export interface YurucommuBackendDiscoveryClientV1 {
@@ -343,7 +365,7 @@ function mountReadinessRoutes(
343
365
  ) => {
344
366
  const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
345
367
  const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
346
- const { clientId } = getOidcClientCredentials(c.env);
368
+ const clientId = getMobileOidcAudience(c.env);
347
369
  return c.json(
348
370
  buildSocialServerDiscovery(appUrl, issuer, discovery, {
349
371
  oidcClientId: getOidcIssuerUrl(c.env)
@@ -504,7 +526,12 @@ function applyBodyLimits(app: YurucommuApp): void {
504
526
  });
505
527
  }
506
528
 
507
- function applyGlobalMiddleware(app: YurucommuApp): void {
529
+ function applyGlobalMiddleware(
530
+ app: YurucommuApp,
531
+ browserMedia: NonNullable<
532
+ CreateYurucommuBackendAppOptionsV1["browserMedia"]
533
+ > = {},
534
+ ): void {
508
535
  app.onError(createErrorMiddleware());
509
536
 
510
537
  app.use("*", async (c, next) => {
@@ -562,11 +589,14 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
562
589
  setSecurityHeader("X-Content-Type-Options", "nosniff");
563
590
  setSecurityHeader("X-Frame-Options", "DENY");
564
591
  setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
565
- // Allow the app's OWN origin to use camera + microphone (WebRTC calls);
566
- // still deny geolocation and deny camera/mic to any cross-origin frame.
592
+ // Browser capture is product-specific and denied by default. Even when a
593
+ // capability is enabled, only this origin may use it; cross-origin frames
594
+ // and geolocation remain denied.
595
+ const camera = browserMedia.camera === true ? "(self)" : "()";
596
+ const microphone = browserMedia.microphone === true ? "(self)" : "()";
567
597
  setSecurityHeader(
568
598
  "Permissions-Policy",
569
- "camera=(self), microphone=(self), geolocation=()",
599
+ `camera=${camera}, microphone=${microphone}, geolocation=()`,
570
600
  );
571
601
  // HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
572
602
  // (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
@@ -901,7 +931,7 @@ export function createYurucommuBackendApp(
901
931
  // /healthz and /readyz stay reachable even when payload validation
902
932
  // misbehaves.
903
933
  applyBodyLimits(app);
904
- applyGlobalMiddleware(app);
934
+ applyGlobalMiddleware(app, options.browserMedia);
905
935
  for (const plugin of plugins) {
906
936
  plugin.setup?.(pluginContext);
907
937
  }
@@ -923,7 +953,7 @@ const app = createYurucommuBackendApp();
923
953
  export const backendApp = app;
924
954
 
925
955
  export async function handleYurucommuQueueBatch(
926
- batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
956
+ batch: IQueueBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
927
957
  env: Env,
928
958
  ): Promise<void> {
929
959
  const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
@@ -931,13 +961,13 @@ export async function handleYurucommuQueueBatch(
931
961
 
932
962
  if (batch.queue === deliveryQueueName) {
933
963
  return handleDeliveryQueueBatch(
934
- batch as MessageBatch<DeliveryQueueMessageV1>,
964
+ batch as IQueueBatch<DeliveryQueueMessageV1>,
935
965
  env,
936
966
  );
937
967
  }
938
968
  if (batch.queue === deliveryDlqName) {
939
969
  return handleDeliveryDlqBatch(
940
- batch as MessageBatch<DeliveryDlqMessageV1>,
970
+ batch as IQueueBatch<DeliveryDlqMessageV1>,
941
971
  env,
942
972
  );
943
973
  }
@@ -966,6 +996,12 @@ type WorkerBindings = EnvVars & {
966
996
  REALTIME_STREAM?: DurableObjectNamespace;
967
997
  };
968
998
 
999
+ function isMaterializedRuntimeEnv(
1000
+ bindings: WorkerBindings | Env,
1001
+ ): bindings is Env {
1002
+ return "DB_INSTANCE" in bindings && !!bindings.DB_INSTANCE;
1003
+ }
1004
+
969
1005
  export default {
970
1006
  async fetch(
971
1007
  request: Request,
@@ -979,6 +1015,20 @@ export default {
979
1015
  batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
980
1016
  bindings: WorkerBindings,
981
1017
  ): Promise<void> {
982
- return handleYurucommuQueueBatch(batch, wrapCloudflareBindings(bindings));
1018
+ return handleYurucommuQueueBatch(
1019
+ wrapCloudflareMessageBatch(batch),
1020
+ wrapCloudflareBindings(bindings),
1021
+ );
1022
+ },
1023
+
1024
+ async scheduled(
1025
+ _controller: ScheduledController,
1026
+ bindings: WorkerBindings | Env,
1027
+ _ctx: ExecutionContext,
1028
+ ): Promise<void> {
1029
+ const env = isMaterializedRuntimeEnv(bindings)
1030
+ ? bindings
1031
+ : wrapCloudflareBindings(bindings);
1032
+ await runYurucommuRetention(env);
983
1033
  },
984
1034
  };
@@ -3,8 +3,8 @@
3
3
  * and batch dispatch of delivery messages.
4
4
  */
5
5
 
6
- import type { Message, MessageBatch } from "@cloudflare/workers-types";
7
6
  import type { Env } from "../../types.ts";
7
+ import type { IQueueMessage } from "../../runtime/queue.ts";
8
8
  import type { Database } from "../../../db/index.ts";
9
9
  import { and, eq, or, sql } from "drizzle-orm";
10
10
  import {
@@ -117,12 +117,17 @@ export async function enqueueFollowerEndpointDeliveries(
117
117
  baseUrl: string,
118
118
  activityId: string,
119
119
  followeeApId: string,
120
- ): Promise<{ processed: number; capped: boolean }> {
120
+ startCursor: string | null = null,
121
+ ): Promise<{
122
+ processed: number;
123
+ capped: boolean;
124
+ nextCursor: string | null;
125
+ }> {
121
126
  // Page through accepted followers with a keyset cursor instead of loading
122
127
  // every row into memory at once. Each page is planned and dispatched in
123
128
  // ≤100-message chunks before the next page is read, bounding both memory
124
129
  // and per-call batch size.
125
- let cursor: string | null = null;
130
+ let cursor: string | null = startCursor;
126
131
  let processed = 0;
127
132
  let capped = false;
128
133
 
@@ -184,7 +189,7 @@ export async function enqueueFollowerEndpointDeliveries(
184
189
  }
185
190
  }
186
191
 
187
- return { processed, capped };
192
+ return { processed, capped, nextCursor: capped ? cursor : null };
188
193
  }
189
194
 
190
195
  /**
@@ -215,21 +220,27 @@ export async function snapshotAndEnqueueFollowerDeliveries(
215
220
  return;
216
221
  }
217
222
 
218
- const { processed, capped } = await enqueueFollowerEndpointDeliveries(
219
- db,
220
- queue,
221
- env.APP_URL,
222
- activityId,
223
- followeeApId,
224
- );
223
+ let cursor: string | null = null;
224
+ let processed = 0;
225
+ do {
226
+ const page = await enqueueFollowerEndpointDeliveries(
227
+ db,
228
+ queue,
229
+ env.APP_URL,
230
+ activityId,
231
+ followeeApId,
232
+ cursor,
233
+ );
234
+ processed += page.processed;
235
+ cursor = page.nextCursor;
236
+ } while (cursor !== null);
225
237
 
226
- if (capped) {
227
- log.warn("Follower snapshot delivery capped at max followers", {
228
- event: "delivery.fanout.snapshot_capped",
238
+ if (processed > FANOUT_MAX_FOLLOWERS) {
239
+ log.info("Follower snapshot continued across bounded pages", {
240
+ event: "delivery.fanout.snapshot_continued",
229
241
  followee: followeeApId,
230
242
  activityId,
231
243
  processed,
232
- max: FANOUT_MAX_FOLLOWERS,
233
244
  });
234
245
  }
235
246
  }
@@ -238,32 +249,38 @@ export async function processFanoutFollowers(
238
249
  db: Database,
239
250
  env: Env,
240
251
  msg: DeliveryFanoutFollowersMessageV1,
241
- message: Message<DeliveryQueueMessageV1>,
252
+ message: IQueueMessage<DeliveryQueueMessageV1>,
242
253
  ): Promise<void> {
243
254
  if (!requireQueue(env, "fanout", message)) return;
244
255
  const queueEnv = env as QueueEnv;
245
256
 
246
- const { processed, capped } = await enqueueFollowerEndpointDeliveries(
247
- db,
248
- queueEnv.DELIVERY_QUEUE,
249
- env.APP_URL,
250
- msg.activityId,
251
- msg.followeeApId,
252
- );
257
+ const { processed, capped, nextCursor } =
258
+ await enqueueFollowerEndpointDeliveries(
259
+ db,
260
+ queueEnv.DELIVERY_QUEUE,
261
+ env.APP_URL,
262
+ msg.activityId,
263
+ msg.followeeApId,
264
+ msg.cursor ?? null,
265
+ );
253
266
 
254
- if (capped) {
255
- // Extremely large follower sets are capped per invocation to keep the
256
- // Worker within CPU/time limits. Endpoint-deduped delivery jobs are
257
- // idempotent (computeDeliveryJobId + upsertDeliveryJob), and the planner
258
- // re-enqueues any still-unknown recipients on the next fanout, so capped
259
- // followers are re-planned on the actor's next delivery rather than lost
260
- // silently.
261
- log.warn("Fanout capped at max followers for one invocation", {
262
- event: "delivery.fanout.capped",
267
+ if (capped && nextCursor !== null) {
268
+ // Send the continuation before ACK. If this send fails, Cloudflare retries
269
+ // the current message; deterministic endpoint job ids make that safe.
270
+ await sendQueueMessage(env, {
271
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
272
+ type: "fanout_followers",
273
+ activityId: msg.activityId,
274
+ followeeApId: msg.followeeApId,
275
+ cursor: nextCursor,
276
+ scheduledAt: nowIso(),
277
+ });
278
+ log.info("Follower fanout continued from stable cursor", {
279
+ event: "delivery.fanout.continued",
263
280
  followee: msg.followeeApId,
264
281
  activityId: msg.activityId,
265
282
  processed,
266
- max: FANOUT_MAX_FOLLOWERS,
283
+ cursor: nextCursor,
267
284
  });
268
285
  }
269
286
 
@@ -288,7 +305,7 @@ export async function processFanoutCommunity(
288
305
  db: Database,
289
306
  env: Env,
290
307
  msg: DeliveryFanoutCommunityMessageV1,
291
- message: Message<DeliveryQueueMessageV1>,
308
+ message: IQueueMessage<DeliveryQueueMessageV1>,
292
309
  ): Promise<void> {
293
310
  const baseUrl = env.APP_URL;
294
311
 
@@ -456,7 +473,7 @@ export async function processResolveActor(
456
473
  db: Database,
457
474
  env: Env,
458
475
  msg: DeliveryResolveActorMessageV1,
459
- message: Message<DeliveryQueueMessageV1>,
476
+ message: IQueueMessage<DeliveryQueueMessageV1>,
460
477
  ): Promise<void> {
461
478
  if (!requireQueue(env, "resolve_actor", message)) return;
462
479
 
@@ -554,7 +571,7 @@ export async function processReconcileJob(
554
571
  db: Database,
555
572
  env: Env,
556
573
  msg: DeliveryReconcileJobMessageV1,
557
- message: Message<DeliveryQueueMessageV1>,
574
+ message: IQueueMessage<DeliveryQueueMessageV1>,
558
575
  ): Promise<void> {
559
576
  if (!requireQueue(env, "reconcile", message)) return;
560
577
 
@@ -3,7 +3,7 @@
3
3
  * Includes signing, circuit breaker checks, retry logic, and dead-letter handling.
4
4
  */
5
5
 
6
- import type { Message } from "@cloudflare/workers-types";
6
+ import type { IQueueMessage } from "../../runtime/queue.ts";
7
7
  import type { Env } from "../../types.ts";
8
8
  import type { Database } from "../../../db/index.ts";
9
9
  import { and, eq, lt, notInArray, or, sql } from "drizzle-orm";
@@ -186,7 +186,7 @@ async function failJob(
186
186
  db: Database,
187
187
  jobId: string,
188
188
  error: string,
189
- message: Message<DeliveryQueueMessageV1>,
189
+ message: IQueueMessage<DeliveryQueueMessageV1>,
190
190
  ): Promise<void> {
191
191
  await db
192
192
  .update(deliveryQueue)
@@ -286,7 +286,7 @@ export async function processDeliverEndpoint(
286
286
  db: Database,
287
287
  env: Env,
288
288
  msg: DeliveryDeliverEndpointMessageV1,
289
- message: Message<DeliveryQueueMessageV1>,
289
+ message: IQueueMessage<DeliveryQueueMessageV1>,
290
290
  bulkhead: Bulkhead,
291
291
  ): Promise<void> {
292
292
  if (!requireQueue(env, "deliver_endpoint", message)) return;
@@ -3,8 +3,12 @@
3
3
  * and the batch handler that dispatches to sub-modules.
4
4
  */
5
5
 
6
- import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
7
6
  import type { Env } from "../../types.ts";
7
+ import type {
8
+ IQueueBatch,
9
+ IQueueMessage,
10
+ IQueueProducer,
11
+ } from "../../runtime/queue.ts";
8
12
  import type { Database } from "../../../db/index.ts";
9
13
  import { and, eq, notInArray, or, sql } from "drizzle-orm";
10
14
  import { actorCache, deliveryQueue } from "../../../db/index.ts";
@@ -119,8 +123,8 @@ export function nowIso(): string {
119
123
  }
120
124
 
121
125
  export type QueueEnv = Env & {
122
- DELIVERY_QUEUE: Queue<DeliveryQueueMessageV1>;
123
- DELIVERY_DLQ: Queue<DeliveryDlqMessageV1>;
126
+ DELIVERY_QUEUE: IQueueProducer<DeliveryQueueMessageV1>;
127
+ DELIVERY_DLQ: IQueueProducer<DeliveryDlqMessageV1>;
124
128
  };
125
129
 
126
130
  function queueAvailable(env: Env): env is QueueEnv {
@@ -130,7 +134,7 @@ function queueAvailable(env: Env): env is QueueEnv {
130
134
  export function requireQueue(
131
135
  env: Env,
132
136
  label: string,
133
- message: Message<DeliveryQueueMessageV1>,
137
+ message: IQueueMessage<DeliveryQueueMessageV1>,
134
138
  ): env is QueueEnv {
135
139
  if (queueAvailable(env)) return true;
136
140
  log.warn("Missing DELIVERY_QUEUE/DELIVERY_DLQ bindings; dropping job", {
@@ -426,7 +430,7 @@ export async function enqueueFanoutToCommunity(
426
430
  // ---------------------------------------------------------------------------
427
431
 
428
432
  export async function handleDeliveryQueueBatch(
429
- batch: MessageBatch<DeliveryQueueMessageV1>,
433
+ batch: IQueueBatch<DeliveryQueueMessageV1>,
430
434
  env: Env,
431
435
  ): Promise<void> {
432
436
  const db = env.DB_INSTANCE;
@@ -494,13 +498,13 @@ export async function handleDeliveryQueueBatch(
494
498
 
495
499
  // Deliver endpoint messages with bulkhead+concurrency.
496
500
  const deliveryMessages = batch.messages.filter(
497
- (m: Message<DeliveryQueueMessageV1>) =>
501
+ (m: IQueueMessage<DeliveryQueueMessageV1>) =>
498
502
  isDeliveryQueueMessageV1(m.body) && m.body.type === "deliver_endpoint",
499
- ) as Array<Message<DeliveryQueueMessageV1>>;
503
+ ) as Array<IQueueMessage<DeliveryQueueMessageV1>>;
500
504
  await runWithConcurrency(
501
505
  deliveryMessages,
502
506
  BULKHEAD_GLOBAL_CONCURRENCY,
503
- async (m: Message<DeliveryQueueMessageV1>) => {
507
+ async (m: IQueueMessage<DeliveryQueueMessageV1>) => {
504
508
  try {
505
509
  await processDeliverEndpoint(
506
510
  db,
@@ -538,7 +542,7 @@ export async function handleDeliveryQueueBatch(
538
542
  }
539
543
 
540
544
  export async function handleDeliveryDlqBatch(
541
- batch: MessageBatch<DeliveryDlqMessageV1>,
545
+ batch: IQueueBatch<DeliveryDlqMessageV1>,
542
546
  env: Env,
543
547
  ): Promise<void> {
544
548
  for (const message of batch.messages) {
@@ -5,6 +5,8 @@ export type DeliveryFanoutFollowersMessageV1 = {
5
5
  type: "fanout_followers";
6
6
  activityId: string;
7
7
  followeeApId: string;
8
+ /** Stable keyset cursor used when a large graph spans queue invocations. */
9
+ cursor?: string;
8
10
  scheduledAt: string; // ISO8601 UTC
9
11
  };
10
12
 
@@ -24,6 +26,10 @@ export type DeliveryFanoutCommunityMessageV1 = {
24
26
  // receive `activityId`. Absent for non-Create activities (edit/delete relay
25
27
  // the activity directly).
26
28
  announceActivityId?: string;
29
+ /** Current bounded fan-out phase; absent means start with local members. */
30
+ stage?: "local_members" | "remote_members" | "remote_followers";
31
+ /** Stable actor-id keyset cursor within `stage`. */
32
+ cursor?: string;
27
33
  scheduledAt: string; // ISO8601 UTC
28
34
  };
29
35
 
@@ -106,12 +112,18 @@ export function isDeliveryQueueMessageV1(
106
112
  return (
107
113
  typeof v.activityId === "string" &&
108
114
  typeof v.followeeApId === "string" &&
115
+ (v.cursor === undefined || typeof v.cursor === "string") &&
109
116
  typeof v.scheduledAt === "string"
110
117
  );
111
118
  case "fanout_community":
112
119
  return (
113
120
  typeof v.activityId === "string" &&
114
121
  typeof v.communityApId === "string" &&
122
+ (v.stage === undefined ||
123
+ v.stage === "local_members" ||
124
+ v.stage === "remote_members" ||
125
+ v.stage === "remote_followers") &&
126
+ (v.cursor === undefined || typeof v.cursor === "string") &&
115
127
  typeof v.scheduledAt === "string"
116
128
  );
117
129
  case "resolve_actor":
@@ -1,4 +1,4 @@
1
- import type { Message } from "@cloudflare/workers-types";
1
+ import type { IQueueMessage } from "../runtime/queue.ts";
2
2
  import { and, asc, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
3
3
 
4
4
  import {
@@ -501,7 +501,7 @@ export async function purgeExpiredNotificationPushJobs(
501
501
  export async function processNotificationPushJob(
502
502
  env: Env,
503
503
  body: DeliveryNotificationPushMessageV1,
504
- message: Message<DeliveryQueueMessageV1>,
504
+ message: IQueueMessage<DeliveryQueueMessageV1>,
505
505
  ): Promise<void> {
506
506
  const db = env.DB_INSTANCE;
507
507
  let job = await db
@@ -61,6 +61,15 @@ export function getOidcClientCredentials(env: Env): {
61
61
  };
62
62
  }
63
63
 
64
+ /**
65
+ * Audience advertised to native clients and accepted by the host exchange.
66
+ * A Capsule has one public OIDC client with multiple exact redirect URIs; web
67
+ * and native clients must therefore converge on this same client id.
68
+ */
69
+ export function getMobileOidcAudience(env: Env): string {
70
+ return getOidcClientCredentials(env).clientId;
71
+ }
72
+
64
73
  export function issuerEndpoint(issuer: string, path: string): string {
65
74
  return `${issuer.replace(/\/$/, "")}${path}`;
66
75
  }