@takosjp/yurucommu-core 3.4.0 → 3.4.1

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 (35) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/src/lib/api/notifications.ts +1 -0
  4. package/packages/api/src/lib/api/posts.ts +1 -0
  5. package/src/backend/index.ts +43 -12
  6. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  7. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  8. package/src/backend/lib/delivery/queue.ts +13 -9
  9. package/src/backend/lib/delivery/types.ts +12 -0
  10. package/src/backend/lib/notification-push.ts +2 -2
  11. package/src/backend/lib/oauth-providers.ts +9 -0
  12. package/src/backend/lib/strip-image-metadata.ts +50 -30
  13. package/src/backend/middleware/bearer-auth.ts +24 -9
  14. package/src/backend/public.ts +22 -1
  15. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  16. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  17. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  18. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  19. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  20. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  21. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  22. package/src/backend/routes/activitypub/inbox.ts +410 -205
  23. package/src/backend/routes/activitypub/outbox.ts +0 -0
  24. package/src/backend/routes/auth.ts +2 -1
  25. package/src/backend/routes/posts/post-helpers.ts +42 -23
  26. package/src/backend/runtime/cloudflare.ts +63 -2
  27. package/src/backend/runtime/managed-relational.ts +197 -0
  28. package/src/backend/runtime/managed-runtime.ts +631 -0
  29. package/src/backend/runtime/queue.ts +40 -0
  30. package/src/backend/server.ts +15 -18
  31. package/src/backend/types.ts +9 -2
  32. package/src/db/d1-write.ts +270 -0
  33. package/src/db/index.ts +17 -0
  34. package/src/db/schema/federation.ts +19 -0
  35. 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.1",
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",
@@ -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,15 @@ 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
+ } from "@cloudflare/workers-types";
48
60
  import type {
49
61
  DeliveryDlqMessageV1,
50
62
  DeliveryQueueMessageV1,
@@ -74,6 +86,14 @@ export interface YurucommuBackendPluginV1 {
74
86
  export interface CreateYurucommuBackendAppOptionsV1 {
75
87
  plugins?: YurucommuBackendPluginV1[];
76
88
  discovery?: YurucommuBackendDiscoveryOptionsV1;
89
+ /**
90
+ * Browser capture capabilities required by this product shell. Both default
91
+ * to denied; a product must opt in to each capability it actually uses.
92
+ */
93
+ browserMedia?: {
94
+ camera?: boolean;
95
+ microphone?: boolean;
96
+ };
77
97
  }
78
98
 
79
99
  export interface YurucommuBackendDiscoveryClientV1 {
@@ -343,7 +363,7 @@ function mountReadinessRoutes(
343
363
  ) => {
344
364
  const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
345
365
  const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
346
- const { clientId } = getOidcClientCredentials(c.env);
366
+ const clientId = getMobileOidcAudience(c.env);
347
367
  return c.json(
348
368
  buildSocialServerDiscovery(appUrl, issuer, discovery, {
349
369
  oidcClientId: getOidcIssuerUrl(c.env)
@@ -504,7 +524,12 @@ function applyBodyLimits(app: YurucommuApp): void {
504
524
  });
505
525
  }
506
526
 
507
- function applyGlobalMiddleware(app: YurucommuApp): void {
527
+ function applyGlobalMiddleware(
528
+ app: YurucommuApp,
529
+ browserMedia: NonNullable<
530
+ CreateYurucommuBackendAppOptionsV1["browserMedia"]
531
+ > = {},
532
+ ): void {
508
533
  app.onError(createErrorMiddleware());
509
534
 
510
535
  app.use("*", async (c, next) => {
@@ -562,11 +587,14 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
562
587
  setSecurityHeader("X-Content-Type-Options", "nosniff");
563
588
  setSecurityHeader("X-Frame-Options", "DENY");
564
589
  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.
590
+ // Browser capture is product-specific and denied by default. Even when a
591
+ // capability is enabled, only this origin may use it; cross-origin frames
592
+ // and geolocation remain denied.
593
+ const camera = browserMedia.camera === true ? "(self)" : "()";
594
+ const microphone = browserMedia.microphone === true ? "(self)" : "()";
567
595
  setSecurityHeader(
568
596
  "Permissions-Policy",
569
- "camera=(self), microphone=(self), geolocation=()",
597
+ `camera=${camera}, microphone=${microphone}, geolocation=()`,
570
598
  );
571
599
  // HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
572
600
  // (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
@@ -901,7 +929,7 @@ export function createYurucommuBackendApp(
901
929
  // /healthz and /readyz stay reachable even when payload validation
902
930
  // misbehaves.
903
931
  applyBodyLimits(app);
904
- applyGlobalMiddleware(app);
932
+ applyGlobalMiddleware(app, options.browserMedia);
905
933
  for (const plugin of plugins) {
906
934
  plugin.setup?.(pluginContext);
907
935
  }
@@ -923,7 +951,7 @@ const app = createYurucommuBackendApp();
923
951
  export const backendApp = app;
924
952
 
925
953
  export async function handleYurucommuQueueBatch(
926
- batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
954
+ batch: IQueueBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
927
955
  env: Env,
928
956
  ): Promise<void> {
929
957
  const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
@@ -931,13 +959,13 @@ export async function handleYurucommuQueueBatch(
931
959
 
932
960
  if (batch.queue === deliveryQueueName) {
933
961
  return handleDeliveryQueueBatch(
934
- batch as MessageBatch<DeliveryQueueMessageV1>,
962
+ batch as IQueueBatch<DeliveryQueueMessageV1>,
935
963
  env,
936
964
  );
937
965
  }
938
966
  if (batch.queue === deliveryDlqName) {
939
967
  return handleDeliveryDlqBatch(
940
- batch as MessageBatch<DeliveryDlqMessageV1>,
968
+ batch as IQueueBatch<DeliveryDlqMessageV1>,
941
969
  env,
942
970
  );
943
971
  }
@@ -979,6 +1007,9 @@ export default {
979
1007
  batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
980
1008
  bindings: WorkerBindings,
981
1009
  ): Promise<void> {
982
- return handleYurucommuQueueBatch(batch, wrapCloudflareBindings(bindings));
1010
+ return handleYurucommuQueueBatch(
1011
+ wrapCloudflareMessageBatch(batch),
1012
+ wrapCloudflareBindings(bindings),
1013
+ );
983
1014
  },
984
1015
  };
@@ -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
  }