@takosjp/yurucommu-core 3.0.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1200 @@
1
+ import type { Message } from "@cloudflare/workers-types";
2
+ import {
3
+ and,
4
+ asc,
5
+ eq,
6
+ exists,
7
+ inArray,
8
+ isNull,
9
+ lte,
10
+ ne,
11
+ notExists,
12
+ or,
13
+ sql,
14
+ } from "drizzle-orm";
15
+
16
+ import {
17
+ activities,
18
+ affectedRowCount,
19
+ communityMembers,
20
+ dmArchivedConversations,
21
+ inbox,
22
+ notificationArchived,
23
+ notificationPushers,
24
+ notificationPushJobs,
25
+ objectRecipients,
26
+ objects,
27
+ type Database,
28
+ } from "../../db/index.ts";
29
+ import type { Actor, Env } from "../types.ts";
30
+ import {
31
+ isLoopbackGatewayUrl,
32
+ normalizeGatewayUrl,
33
+ type JsonObject,
34
+ type ParsedNotificationPusherDeleteRequest,
35
+ type ParsedNotificationPusherSetRequest,
36
+ type SocialNotificationProduct,
37
+ } from "./notification-pusher-contract.ts";
38
+ import {
39
+ DELIVERY_QUEUE_MESSAGE_VERSION,
40
+ type DeliveryNotificationPushMessageV1,
41
+ type DeliveryQueueMessageV1,
42
+ } from "./delivery/types.ts";
43
+ import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
44
+ import { logger } from "./logger.ts";
45
+ import { generateId } from "./oauth-utils.ts";
46
+
47
+ const log = logger.child({ component: "notification.push" });
48
+
49
+ export const MAX_NOTIFICATION_PUSHERS_PER_PRODUCT = 16;
50
+ export const MAX_NOTIFICATION_PUSHERS_PER_APP = 8;
51
+ export const MAX_NOTIFICATION_PUSH_DISPATCH = 16;
52
+ export const MAX_NOTIFICATION_PUSH_ATTEMPTS = 5;
53
+ export const NOTIFICATION_PUSHER_RETENTION_DAYS = 90;
54
+ export const NOTIFICATION_PUSH_JOB_RETENTION_DAYS = 90;
55
+ export const MAX_NOTIFICATION_PUSH_JOB_PURGE = 50;
56
+
57
+ const DEFAULT_GATEWAY_TIMEOUT_MS = 10_000;
58
+ const MAX_GATEWAY_RESPONSE_BYTES = 64 * 1024;
59
+ const MAX_QUEUE_SCAN = 50;
60
+ const STALE_PROCESSING_MS = 2 * 60 * 1000;
61
+ const SOCIAL_NOTIFICATION_ACTIVITY_TYPES = [
62
+ "Follow",
63
+ "Like",
64
+ "Announce",
65
+ "Create",
66
+ ] as const;
67
+
68
+ export interface NotificationPusherRegistrationResponse {
69
+ readonly id: string;
70
+ readonly kind: "http";
71
+ readonly app_id: string;
72
+ readonly app_display_name?: string;
73
+ readonly device_display_name?: string;
74
+ readonly profile_tag?: string;
75
+ readonly lang?: string;
76
+ readonly data: JsonObject;
77
+ readonly gateway_url: string;
78
+ readonly product: SocialNotificationProduct;
79
+ readonly scope: string | null;
80
+ readonly registered_at: string;
81
+ readonly last_seen_at: string;
82
+ }
83
+
84
+ type StoredPusher = {
85
+ id: string;
86
+ actorApId: string;
87
+ product: string;
88
+ appId: string;
89
+ pushkey: string;
90
+ pushkeyHash: string;
91
+ dataJson: string;
92
+ gatewayUrl: string;
93
+ };
94
+
95
+ type GatewayResult = {
96
+ retryIds: string[];
97
+ retryAfterSeconds: number;
98
+ error: string | null;
99
+ };
100
+
101
+ type NotificationPushFormat = "event_id_only" | "full";
102
+
103
+ type GatewayDispatchGroup = {
104
+ gatewayUrl: string;
105
+ format: NotificationPushFormat;
106
+ pushers: StoredPusher[];
107
+ };
108
+
109
+ type ProcessingLease = {
110
+ jobId: string;
111
+ processingToken: string;
112
+ };
113
+
114
+ export function isNotificationGatewayAllowed(env: Env, value: string): boolean {
115
+ const normalized = normalizeGatewayUrl(value);
116
+ if (!normalized) return false;
117
+ if (isLoopbackGatewayUrl(normalized)) {
118
+ return isTruthy(env.YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK);
119
+ }
120
+ const host = new URL(normalized).hostname.toLowerCase();
121
+ const allowed = new Set(
122
+ (env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS ?? "")
123
+ .split(",")
124
+ .map((entry) => entry.trim().toLowerCase())
125
+ .filter(Boolean),
126
+ );
127
+ return allowed.has(host);
128
+ }
129
+
130
+ export async function registerNotificationPusher(
131
+ db: Database,
132
+ actor: Actor,
133
+ input: ParsedNotificationPusherSetRequest,
134
+ ): Promise<NotificationPusherRegistrationResponse> {
135
+ const now = new Date().toISOString();
136
+ const pushkeyHash = await sha256Hex(input.pusher.pushkey);
137
+
138
+ // The device key is globally unique inside product+app. Reassigning that row
139
+ // is one atomic upsert, so concurrent logins cannot leave duplicate owners.
140
+ const existing = await db
141
+ .select({
142
+ id: notificationPushers.id,
143
+ actorApId: notificationPushers.actorApId,
144
+ })
145
+ .from(notificationPushers)
146
+ .where(
147
+ and(
148
+ eq(notificationPushers.product, input.product),
149
+ eq(notificationPushers.appId, input.pusher.app_id),
150
+ eq(notificationPushers.pushkeyHash, pushkeyHash),
151
+ ),
152
+ )
153
+ .get();
154
+
155
+ const sameActor = existing?.actorApId === actor.ap_id;
156
+ if (!sameActor) {
157
+ await enforcePusherQuota(
158
+ db,
159
+ actor.ap_id,
160
+ input.product,
161
+ input.pusher.app_id,
162
+ );
163
+ }
164
+ const registrationId = sameActor ? existing.id : generateId(16);
165
+
166
+ await db
167
+ .insert(notificationPushers)
168
+ .values({
169
+ id: registrationId,
170
+ actorApId: actor.ap_id,
171
+ product: input.product,
172
+ scope: input.scope,
173
+ kind: "http",
174
+ appId: input.pusher.app_id,
175
+ pushkey: input.pusher.pushkey,
176
+ pushkeyHash,
177
+ appDisplayName: input.pusher.app_display_name ?? null,
178
+ deviceDisplayName: input.pusher.device_display_name ?? null,
179
+ profileTag: input.pusher.profile_tag ?? null,
180
+ lang: input.pusher.lang ?? null,
181
+ dataJson: JSON.stringify(input.storedData),
182
+ gatewayUrl: input.gatewayUrl,
183
+ createdAt: now,
184
+ updatedAt: now,
185
+ lastSeenAt: now,
186
+ })
187
+ .onConflictDoUpdate({
188
+ target: [
189
+ notificationPushers.product,
190
+ notificationPushers.appId,
191
+ notificationPushers.pushkeyHash,
192
+ ],
193
+ set: {
194
+ id: registrationId,
195
+ actorApId: actor.ap_id,
196
+ kind: "http",
197
+ pushkey: input.pusher.pushkey,
198
+ scope: input.scope,
199
+ appDisplayName: input.pusher.app_display_name ?? null,
200
+ deviceDisplayName: input.pusher.device_display_name ?? null,
201
+ profileTag: input.pusher.profile_tag ?? null,
202
+ lang: input.pusher.lang ?? null,
203
+ dataJson: JSON.stringify(input.storedData),
204
+ gatewayUrl: input.gatewayUrl,
205
+ ...(sameActor ? {} : { createdAt: now }),
206
+ updatedAt: now,
207
+ lastSeenAt: now,
208
+ },
209
+ });
210
+
211
+ const row = await db
212
+ .select()
213
+ .from(notificationPushers)
214
+ .where(
215
+ and(
216
+ eq(notificationPushers.actorApId, actor.ap_id),
217
+ eq(notificationPushers.product, input.product),
218
+ eq(notificationPushers.appId, input.pusher.app_id),
219
+ eq(notificationPushers.pushkeyHash, pushkeyHash),
220
+ ),
221
+ )
222
+ .get();
223
+ if (!row) throw new Error("Failed to register notification pusher");
224
+ return {
225
+ id: row.id,
226
+ kind: "http",
227
+ app_id: row.appId,
228
+ ...(row.appDisplayName ? { app_display_name: row.appDisplayName } : {}),
229
+ ...(row.deviceDisplayName
230
+ ? { device_display_name: row.deviceDisplayName }
231
+ : {}),
232
+ ...(row.profileTag ? { profile_tag: row.profileTag } : {}),
233
+ ...(row.lang ? { lang: row.lang } : {}),
234
+ data: parseStoredData(row.dataJson),
235
+ gateway_url: row.gatewayUrl,
236
+ product: input.product,
237
+ scope: row.scope,
238
+ registered_at: row.createdAt,
239
+ last_seen_at: row.lastSeenAt,
240
+ };
241
+ }
242
+
243
+ export async function deleteNotificationPusher(
244
+ db: Database,
245
+ actor: Actor,
246
+ input: ParsedNotificationPusherDeleteRequest,
247
+ ): Promise<void> {
248
+ const hash = await sha256Hex(input.pushkey);
249
+ await db
250
+ .delete(notificationPushers)
251
+ .where(
252
+ and(
253
+ eq(notificationPushers.actorApId, actor.ap_id),
254
+ eq(notificationPushers.product, input.product),
255
+ eq(notificationPushers.appId, input.appId),
256
+ eq(notificationPushers.pushkeyHash, hash),
257
+ eq(notificationPushers.pushkey, input.pushkey),
258
+ input.scope === null
259
+ ? undefined
260
+ : eq(notificationPushers.scope, input.scope),
261
+ ),
262
+ );
263
+ }
264
+
265
+ async function enforcePusherQuota(
266
+ db: Database,
267
+ actorApId: string,
268
+ product: SocialNotificationProduct,
269
+ appId: string,
270
+ ): Promise<void> {
271
+ const [sameApp, sameProduct] = await Promise.all([
272
+ db
273
+ .select({ id: notificationPushers.id })
274
+ .from(notificationPushers)
275
+ .where(
276
+ and(
277
+ eq(notificationPushers.actorApId, actorApId),
278
+ eq(notificationPushers.product, product),
279
+ eq(notificationPushers.appId, appId),
280
+ ),
281
+ )
282
+ .orderBy(asc(notificationPushers.createdAt)),
283
+ db
284
+ .select({ id: notificationPushers.id })
285
+ .from(notificationPushers)
286
+ .where(
287
+ and(
288
+ eq(notificationPushers.actorApId, actorApId),
289
+ eq(notificationPushers.product, product),
290
+ ),
291
+ )
292
+ .orderBy(asc(notificationPushers.createdAt)),
293
+ ]);
294
+ const evict = new Set<string>();
295
+ for (const row of sameApp.slice(
296
+ 0,
297
+ Math.max(0, sameApp.length + 1 - MAX_NOTIFICATION_PUSHERS_PER_APP),
298
+ )) {
299
+ evict.add(row.id);
300
+ }
301
+ const remainingProduct = sameProduct.filter((row) => !evict.has(row.id));
302
+ for (const row of remainingProduct.slice(
303
+ 0,
304
+ Math.max(
305
+ 0,
306
+ remainingProduct.length + 1 - MAX_NOTIFICATION_PUSHERS_PER_PRODUCT,
307
+ ),
308
+ )) {
309
+ evict.add(row.id);
310
+ }
311
+ if (evict.size > 0) {
312
+ await db
313
+ .delete(notificationPushers)
314
+ .where(inArray(notificationPushers.id, [...evict]));
315
+ }
316
+ }
317
+
318
+ export function buildNotificationPushMessage(
319
+ jobId: string,
320
+ ): DeliveryNotificationPushMessageV1 {
321
+ return {
322
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
323
+ type: "notification_push",
324
+ jobId,
325
+ scheduledAt: new Date().toISOString(),
326
+ };
327
+ }
328
+
329
+ /** Enqueue durable outbox rows. Safe to call after every request/queue batch. */
330
+ export async function enqueuePendingNotificationPushJobs(
331
+ env: Env,
332
+ ): Promise<number> {
333
+ // Terminal rows are retained long enough to preserve the deterministic job
334
+ // idempotency window, then removed opportunistically in a bounded batch.
335
+ // This runs even without a Queue binding so disabled push delivery cannot
336
+ // turn the outbox ledger into unbounded storage.
337
+ await purgeExpiredNotificationPushJobs(env.DB_INSTANCE);
338
+ if (!env.DELIVERY_QUEUE) return 0;
339
+ const now = new Date().toISOString();
340
+ const rows = await env.DB_INSTANCE.select({ id: notificationPushJobs.id })
341
+ .from(notificationPushJobs)
342
+ .where(
343
+ and(
344
+ or(
345
+ eq(notificationPushJobs.status, "pending"),
346
+ eq(notificationPushJobs.status, "retry_wait"),
347
+ ),
348
+ lte(notificationPushJobs.nextAttemptAt, now),
349
+ ),
350
+ )
351
+ .orderBy(asc(notificationPushJobs.createdAt))
352
+ .limit(MAX_QUEUE_SCAN);
353
+ if (rows.length === 0) return 0;
354
+
355
+ await env.DELIVERY_QUEUE.sendBatch(
356
+ rows.map((row) => ({ body: buildNotificationPushMessage(row.id) })),
357
+ );
358
+ await env.DB_INSTANCE.update(notificationPushJobs)
359
+ .set({ status: "queued", processingToken: null, updatedAt: now })
360
+ .where(
361
+ and(
362
+ inArray(
363
+ notificationPushJobs.id,
364
+ rows.map((row) => row.id),
365
+ ),
366
+ or(
367
+ eq(notificationPushJobs.status, "pending"),
368
+ eq(notificationPushJobs.status, "retry_wait"),
369
+ ),
370
+ ),
371
+ );
372
+ return rows.length;
373
+ }
374
+
375
+ /** Remove at most one bounded batch of expired terminal delivery jobs. */
376
+ export async function purgeExpiredNotificationPushJobs(
377
+ db: Database,
378
+ now = new Date(),
379
+ ): Promise<number> {
380
+ const cutoff = new Date(
381
+ now.getTime() - NOTIFICATION_PUSH_JOB_RETENTION_DAYS * 86_400_000,
382
+ ).toISOString();
383
+ const terminalStatuses = ["delivered", "failed"] as const;
384
+ const expired = await db
385
+ .select({ id: notificationPushJobs.id })
386
+ .from(notificationPushJobs)
387
+ .where(
388
+ and(
389
+ inArray(notificationPushJobs.status, terminalStatuses),
390
+ lte(notificationPushJobs.updatedAt, cutoff),
391
+ ),
392
+ )
393
+ .orderBy(asc(notificationPushJobs.updatedAt), asc(notificationPushJobs.id))
394
+ .limit(MAX_NOTIFICATION_PUSH_JOB_PURGE);
395
+ if (expired.length === 0) return 0;
396
+
397
+ const deleted = await db.delete(notificationPushJobs).where(
398
+ and(
399
+ inArray(
400
+ notificationPushJobs.id,
401
+ expired.map((row) => row.id),
402
+ ),
403
+ inArray(notificationPushJobs.status, terminalStatuses),
404
+ lte(notificationPushJobs.updatedAt, cutoff),
405
+ ),
406
+ );
407
+ return affectedRowCount(deleted);
408
+ }
409
+
410
+ export async function processNotificationPushJob(
411
+ env: Env,
412
+ body: DeliveryNotificationPushMessageV1,
413
+ message: Message<DeliveryQueueMessageV1>,
414
+ ): Promise<void> {
415
+ const db = env.DB_INSTANCE;
416
+ let job = await db
417
+ .select()
418
+ .from(notificationPushJobs)
419
+ .where(eq(notificationPushJobs.id, body.jobId))
420
+ .get();
421
+ if (!job || job.status === "delivered" || job.status === "failed") {
422
+ message.ack();
423
+ return;
424
+ }
425
+
426
+ const processingAgeMs = Date.now() - Date.parse(job.updatedAt);
427
+ if (job.status === "processing") {
428
+ if (processingAgeMs < STALE_PROCESSING_MS) {
429
+ message.retry({ delaySeconds: 30 });
430
+ return;
431
+ }
432
+ // Reclaim a stale processing row through an actual status transition.
433
+ // Updating processing -> processing lets two workers that read the same
434
+ // stale row both satisfy the old broad claim predicate and duplicate a
435
+ // push. Only the worker that wins this processing -> queued CAS may proceed.
436
+ const reclaimedAt = new Date().toISOString();
437
+ const reclaimed = await db
438
+ .update(notificationPushJobs)
439
+ .set({
440
+ status: "queued",
441
+ processingToken: null,
442
+ updatedAt: reclaimedAt,
443
+ })
444
+ .where(
445
+ and(
446
+ eq(notificationPushJobs.id, job.id),
447
+ eq(notificationPushJobs.status, "processing"),
448
+ eq(notificationPushJobs.updatedAt, job.updatedAt),
449
+ ),
450
+ );
451
+ if (affectedRowCount(reclaimed) === 0) {
452
+ message.retry({ delaySeconds: 30 });
453
+ return;
454
+ }
455
+ job = {
456
+ ...job,
457
+ status: "queued",
458
+ processingToken: null,
459
+ updatedAt: reclaimedAt,
460
+ };
461
+ }
462
+
463
+ const now = new Date().toISOString();
464
+ const processingToken = generateId(16);
465
+ const claimed = await db
466
+ .update(notificationPushJobs)
467
+ .set({ status: "processing", processingToken, updatedAt: now })
468
+ .where(
469
+ and(
470
+ eq(notificationPushJobs.id, job.id),
471
+ eq(notificationPushJobs.status, job.status),
472
+ eq(notificationPushJobs.updatedAt, job.updatedAt),
473
+ ),
474
+ );
475
+ if (affectedRowCount(claimed) === 0) {
476
+ // Another Queue delivery owns this job now. Do not ack the competing
477
+ // message permanently: a retry lets it observe the terminal row or recover
478
+ // if that owner crashes after claiming.
479
+ message.retry({ delaySeconds: 30 });
480
+ return;
481
+ }
482
+ const lease: ProcessingLease = { jobId: job.id, processingToken };
483
+
484
+ try {
485
+ const explicitProduct =
486
+ job.product === "yurucommu" || job.product === "yurume"
487
+ ? job.product
488
+ : null;
489
+ const event = await loadPushEvent(
490
+ db,
491
+ job.actorApId,
492
+ job.activityApId,
493
+ explicitProduct,
494
+ );
495
+ if (!event) {
496
+ await finishJob(db, lease, "notification is no longer eligible");
497
+ message.ack();
498
+ return;
499
+ }
500
+
501
+ const product: SocialNotificationProduct =
502
+ explicitProduct ??
503
+ (event.visibility === "direct" ? "yurume" : "yurucommu");
504
+ let pendingIds = parsePendingIds(job.pendingPusherIdsJson);
505
+ if (pendingIds === null) {
506
+ const cutoff = new Date(
507
+ Date.now() - NOTIFICATION_PUSHER_RETENTION_DAYS * 86_400_000,
508
+ ).toISOString();
509
+ await db
510
+ .delete(notificationPushers)
511
+ .where(lte(notificationPushers.lastSeenAt, cutoff));
512
+ pendingIds = (
513
+ await db
514
+ .select({ id: notificationPushers.id })
515
+ .from(notificationPushers)
516
+ .where(
517
+ and(
518
+ eq(notificationPushers.actorApId, job.actorApId),
519
+ eq(notificationPushers.product, product),
520
+ lte(notificationPushers.createdAt, job.createdAt),
521
+ ),
522
+ )
523
+ .orderBy(asc(notificationPushers.createdAt))
524
+ .limit(MAX_NOTIFICATION_PUSH_DISPATCH)
525
+ ).map((row) => row.id);
526
+ const pendingIdsUpdated = await db
527
+ .update(notificationPushJobs)
528
+ .set({
529
+ pendingPusherIdsJson: JSON.stringify(pendingIds),
530
+ updatedAt: now,
531
+ })
532
+ .where(processingLeaseWhere(lease));
533
+ if (affectedRowCount(pendingIdsUpdated) === 0) {
534
+ message.ack();
535
+ return;
536
+ }
537
+ }
538
+
539
+ if (pendingIds.length === 0) {
540
+ await finishJob(db, lease, null);
541
+ message.ack();
542
+ return;
543
+ }
544
+
545
+ const pushers = (await db
546
+ .select({
547
+ id: notificationPushers.id,
548
+ actorApId: notificationPushers.actorApId,
549
+ product: notificationPushers.product,
550
+ appId: notificationPushers.appId,
551
+ pushkey: notificationPushers.pushkey,
552
+ pushkeyHash: notificationPushers.pushkeyHash,
553
+ dataJson: notificationPushers.dataJson,
554
+ gatewayUrl: notificationPushers.gatewayUrl,
555
+ })
556
+ .from(notificationPushers)
557
+ .where(
558
+ and(
559
+ inArray(notificationPushers.id, pendingIds),
560
+ eq(notificationPushers.actorApId, job.actorApId),
561
+ eq(notificationPushers.product, product),
562
+ ),
563
+ )) as StoredPusher[];
564
+ if (pushers.length === 0) {
565
+ await finishJob(db, lease, null);
566
+ message.ack();
567
+ return;
568
+ }
569
+
570
+ const unread = await unreadCountForProduct(db, job.actorApId, product);
571
+ const grouped = groupByGatewayAndFormat(pushers);
572
+ const retryIds: string[] = [];
573
+ let retryAfterSeconds = 0;
574
+ const errors: string[] = [];
575
+ for (const group of grouped.values()) {
576
+ // A job can fan out to sixteen different gateways. Refresh the durable
577
+ // lease before each bounded network call so a healthy worker cannot age
578
+ // past the stale-reclaim window while progressing through that fanout.
579
+ if (!(await refreshProcessingLease(db, lease))) {
580
+ message.ack();
581
+ return;
582
+ }
583
+ const outcome = await deliverGatewayGroup(
584
+ env,
585
+ db,
586
+ group.gatewayUrl,
587
+ group.pushers,
588
+ group.format,
589
+ {
590
+ id: event.activityApId,
591
+ type: event.visibility === "direct" ? "dm" : event.type.toLowerCase(),
592
+ sender: event.actorApId,
593
+ scopeId: event.objectApId,
594
+ unread,
595
+ },
596
+ );
597
+ retryIds.push(...outcome.retryIds);
598
+ retryAfterSeconds = Math.max(
599
+ retryAfterSeconds,
600
+ outcome.retryAfterSeconds,
601
+ );
602
+ if (outcome.error) errors.push(outcome.error);
603
+ }
604
+
605
+ if (retryIds.length === 0) {
606
+ await finishJob(db, lease, errors.length > 0 ? errors.join("; ") : null);
607
+ message.ack();
608
+ return;
609
+ }
610
+
611
+ const attempts = job.attempts + 1;
612
+ const lastError =
613
+ errors.join("; ").slice(0, 1024) || "retryable gateway failure";
614
+ if (attempts >= MAX_NOTIFICATION_PUSH_ATTEMPTS) {
615
+ const failed = await db
616
+ .update(notificationPushJobs)
617
+ .set({
618
+ status: "failed",
619
+ processingToken: null,
620
+ attempts,
621
+ pendingPusherIdsJson: JSON.stringify([...new Set(retryIds)]),
622
+ lastError,
623
+ updatedAt: new Date().toISOString(),
624
+ })
625
+ .where(processingLeaseWhere(lease));
626
+ if (affectedRowCount(failed) === 0) {
627
+ message.ack();
628
+ return;
629
+ }
630
+ log.error("Notification push exhausted its retry budget", {
631
+ event: "notification.push.exhausted",
632
+ jobId: job.id,
633
+ attempts,
634
+ });
635
+ message.ack();
636
+ return;
637
+ }
638
+
639
+ const delaySeconds = Math.max(
640
+ retryAfterSeconds,
641
+ Math.min(12 * 60 * 60, 30 * 2 ** Math.max(0, attempts - 1)),
642
+ );
643
+ const nextAttemptAt = new Date(
644
+ Date.now() + delaySeconds * 1000,
645
+ ).toISOString();
646
+ const retryScheduled = await db
647
+ .update(notificationPushJobs)
648
+ .set({
649
+ status: "retry_wait",
650
+ processingToken: null,
651
+ attempts,
652
+ pendingPusherIdsJson: JSON.stringify([...new Set(retryIds)]),
653
+ nextAttemptAt,
654
+ lastError,
655
+ updatedAt: new Date().toISOString(),
656
+ })
657
+ .where(processingLeaseWhere(lease));
658
+ if (affectedRowCount(retryScheduled) === 0) {
659
+ message.ack();
660
+ return;
661
+ }
662
+ message.retry({ delaySeconds });
663
+ } catch (error) {
664
+ const attempts = job.attempts + 1;
665
+ const errorText = error instanceof Error ? error.message : String(error);
666
+ if (attempts >= MAX_NOTIFICATION_PUSH_ATTEMPTS) {
667
+ const failed = await db
668
+ .update(notificationPushJobs)
669
+ .set({
670
+ status: "failed",
671
+ processingToken: null,
672
+ attempts,
673
+ lastError: errorText.slice(0, 1024),
674
+ updatedAt: new Date().toISOString(),
675
+ })
676
+ .where(processingLeaseWhere(lease));
677
+ if (affectedRowCount(failed) === 0) {
678
+ message.ack();
679
+ return;
680
+ }
681
+ message.ack();
682
+ return;
683
+ }
684
+ const delaySeconds = Math.min(12 * 60 * 60, 30 * 2 ** (attempts - 1));
685
+ const retryScheduled = await db
686
+ .update(notificationPushJobs)
687
+ .set({
688
+ status: "retry_wait",
689
+ processingToken: null,
690
+ attempts,
691
+ nextAttemptAt: new Date(Date.now() + delaySeconds * 1000).toISOString(),
692
+ lastError: errorText.slice(0, 1024),
693
+ updatedAt: new Date().toISOString(),
694
+ })
695
+ .where(processingLeaseWhere(lease));
696
+ if (affectedRowCount(retryScheduled) === 0) {
697
+ message.ack();
698
+ return;
699
+ }
700
+ message.retry({ delaySeconds });
701
+ }
702
+ }
703
+
704
+ async function deliverGatewayGroup(
705
+ env: Env,
706
+ db: Database,
707
+ gatewayUrl: string,
708
+ pushers: StoredPusher[],
709
+ format: NotificationPushFormat,
710
+ event: {
711
+ id: string;
712
+ type: string;
713
+ sender: string;
714
+ scopeId: string | null;
715
+ unread: number;
716
+ },
717
+ ): Promise<GatewayResult> {
718
+ if (!isNotificationGatewayAllowed(env, gatewayUrl)) {
719
+ return {
720
+ retryIds: [],
721
+ retryAfterSeconds: 0,
722
+ error: "gateway is no longer operator-allowed",
723
+ };
724
+ }
725
+ const data = pushers.map((row) => normalizedStoredData(row.dataJson));
726
+ const eventIdOnly = format === "event_id_only";
727
+ const payload = {
728
+ notification: {
729
+ event_id: event.id,
730
+ room_id: event.scopeId ?? undefined,
731
+ counts: { unread: event.unread },
732
+ ...(!eventIdOnly
733
+ ? {
734
+ type: event.type,
735
+ sender: event.sender,
736
+ user_is_target: true,
737
+ prio: "high",
738
+ }
739
+ : {}),
740
+ devices: pushers.map((row, index) => ({
741
+ app_id: row.appId,
742
+ pushkey: row.pushkey,
743
+ pushkey_ts: Math.floor(Date.now() / 1000),
744
+ data: data[index],
745
+ })),
746
+ },
747
+ };
748
+
749
+ const controller = new AbortController();
750
+ const timeout = setTimeout(
751
+ () => controller.abort(),
752
+ parseTimeout(env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS),
753
+ );
754
+ let response: Response;
755
+ try {
756
+ const headers = new Headers({ "Content-Type": "application/json" });
757
+ const canonical = normalizeGatewayUrl(
758
+ env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL,
759
+ );
760
+ if (
761
+ canonical === normalizeGatewayUrl(gatewayUrl) &&
762
+ env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN &&
763
+ new URL(gatewayUrl).protocol === "https:"
764
+ ) {
765
+ headers.set(
766
+ "Authorization",
767
+ `Bearer ${env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN}`,
768
+ );
769
+ }
770
+ response = await fetch(gatewayUrl, {
771
+ method: "POST",
772
+ headers,
773
+ body: JSON.stringify(payload),
774
+ redirect: "error",
775
+ signal: controller.signal,
776
+ });
777
+ } catch (error) {
778
+ return {
779
+ retryIds: pushers.map((row) => row.id),
780
+ retryAfterSeconds: 0,
781
+ error: error instanceof Error ? error.message : String(error),
782
+ };
783
+ } finally {
784
+ clearTimeout(timeout);
785
+ }
786
+
787
+ if (response.status === 429 || response.status >= 500) {
788
+ return {
789
+ retryIds: pushers.map((row) => row.id),
790
+ retryAfterSeconds: parseRetryAfter(response.headers.get("Retry-After")),
791
+ error: `gateway HTTP ${response.status}`,
792
+ };
793
+ }
794
+ if (!response.ok) {
795
+ return {
796
+ retryIds: [],
797
+ retryAfterSeconds: 0,
798
+ error: `gateway HTTP ${response.status}`,
799
+ };
800
+ }
801
+
802
+ let parsed: unknown;
803
+ try {
804
+ parsed = JSON.parse(
805
+ await readResponseText(response, MAX_GATEWAY_RESPONSE_BYTES),
806
+ );
807
+ } catch (error) {
808
+ return {
809
+ retryIds: pushers.map((row) => row.id),
810
+ retryAfterSeconds: 0,
811
+ error:
812
+ error instanceof Error ? error.message : "invalid gateway response",
813
+ };
814
+ }
815
+ if (!parsed || typeof parsed !== "object") {
816
+ return {
817
+ retryIds: pushers.map((row) => row.id),
818
+ retryAfterSeconds: 0,
819
+ error: "invalid gateway response",
820
+ };
821
+ }
822
+ const responseBody = parsed as Record<string, unknown>;
823
+ const rejected = stringArray(responseBody.rejected);
824
+ const retryable = stringArray(responseBody.retryable);
825
+ const failed = stringArray(responseBody.failed);
826
+ if (!rejected || !retryable || !failed) {
827
+ return {
828
+ retryIds: pushers.map((row) => row.id),
829
+ retryAfterSeconds: 0,
830
+ error: "invalid gateway result arrays",
831
+ };
832
+ }
833
+
834
+ const byPushkey = new Map(pushers.map((row) => [row.pushkey, row]));
835
+ for (const pushkey of rejected) {
836
+ const row = byPushkey.get(pushkey);
837
+ if (!row) continue;
838
+ await db
839
+ .delete(notificationPushers)
840
+ .where(
841
+ and(
842
+ eq(notificationPushers.id, row.id),
843
+ eq(notificationPushers.actorApId, row.actorApId),
844
+ eq(notificationPushers.product, row.product),
845
+ eq(notificationPushers.pushkeyHash, row.pushkeyHash),
846
+ eq(notificationPushers.pushkey, row.pushkey),
847
+ ),
848
+ );
849
+ }
850
+ const terminal = new Set([...rejected, ...failed]);
851
+ const retrySet = new Set(retryable);
852
+ return {
853
+ retryIds: pushers
854
+ .filter((row) => retrySet.has(row.pushkey) && !terminal.has(row.pushkey))
855
+ .map((row) => row.id),
856
+ retryAfterSeconds: parseRetryAfter(response.headers.get("Retry-After")),
857
+ error: failed.length > 0 ? "gateway reported permanent failures" : null,
858
+ };
859
+ }
860
+
861
+ async function loadPushEvent(
862
+ db: Database,
863
+ actorApId: string,
864
+ activityApId: string,
865
+ explicitProduct: SocialNotificationProduct | null,
866
+ ) {
867
+ const selectEvent = () =>
868
+ db.select({
869
+ activityApId: activities.apId,
870
+ type: activities.type,
871
+ actorApId: activities.actorApId,
872
+ objectApId: activities.objectApId,
873
+ objectType: objects.type,
874
+ visibility: objects.visibility,
875
+ conversation: objects.conversation,
876
+ });
877
+
878
+ if (explicitProduct !== null) {
879
+ // Explicit jobs currently represent community talk, which intentionally
880
+ // has no social-inbox row. It still observes per-recipient block/mute and
881
+ // self-notification rules at delivery time.
882
+ const currentCommunityMembership = db
883
+ .select({ actorApId: communityMembers.actorApId })
884
+ .from(objectRecipients)
885
+ .innerJoin(
886
+ communityMembers,
887
+ eq(communityMembers.communityApId, objectRecipients.recipientApId),
888
+ )
889
+ .where(
890
+ and(
891
+ eq(objectRecipients.objectApId, activities.objectApId),
892
+ eq(objectRecipients.type, "audience"),
893
+ eq(communityMembers.actorApId, actorApId),
894
+ ),
895
+ );
896
+ return selectEvent()
897
+ .from(activities)
898
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
899
+ .where(
900
+ and(
901
+ eq(activities.apId, activityApId),
902
+ ne(activities.actorApId, actorApId),
903
+ inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
904
+ exists(currentCommunityMembership),
905
+ excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
906
+ ),
907
+ )
908
+ .get();
909
+ }
910
+
911
+ // The inbox trigger intentionally captures every unread insert so it cannot
912
+ // lose a notification in a route-specific crash window. Eligibility is
913
+ // therefore re-checked here immediately before external delivery. This
914
+ // mirrors the notification list/count choke point: only user-facing activity
915
+ // types, never self events, never archived rows, and never blocked/muted
916
+ // senders. Direct Creates remain eligible and are routed to Yurume below.
917
+ const archivedCorrelation = and(
918
+ eq(notificationArchived.actorApId, inbox.actorApId),
919
+ eq(notificationArchived.activityApId, inbox.activityApId),
920
+ );
921
+ const archivedSubquery = db
922
+ .select({ activityApId: notificationArchived.activityApId })
923
+ .from(notificationArchived)
924
+ .where(archivedCorrelation);
925
+ const archivedDmSubquery = db
926
+ .select({ conversationId: dmArchivedConversations.conversationId })
927
+ .from(dmArchivedConversations)
928
+ .where(
929
+ and(
930
+ eq(dmArchivedConversations.actorApId, actorApId),
931
+ eq(dmArchivedConversations.conversationId, objects.conversation),
932
+ ),
933
+ );
934
+ return selectEvent()
935
+ .from(inbox)
936
+ .innerJoin(activities, eq(inbox.activityApId, activities.apId))
937
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
938
+ .where(
939
+ and(
940
+ eq(inbox.actorApId, actorApId),
941
+ eq(inbox.activityApId, activityApId),
942
+ ne(activities.actorApId, actorApId),
943
+ inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
944
+ notExists(archivedSubquery),
945
+ // Archiving a Yurume conversation is a current delivery preference,
946
+ // not merely a presentation filter. A queued direct event must stop at
947
+ // this choke point if the recipient archived it after enqueue.
948
+ or(
949
+ isNull(objects.visibility),
950
+ ne(objects.visibility, "direct"),
951
+ notExists(archivedDmSubquery),
952
+ )!,
953
+ excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
954
+ ),
955
+ )
956
+ .get();
957
+ }
958
+
959
+ /** Product-specific unread badge included in the event-id-only push payload. */
960
+ async function unreadCountForProduct(
961
+ db: Database,
962
+ actorApId: string,
963
+ product: SocialNotificationProduct,
964
+ ): Promise<number> {
965
+ if (product === "yurume") {
966
+ // Keep parity with GET /api/dm/unread/count: direct messages use their
967
+ // per-conversation read baseline, while community talk uses the later of
968
+ // the membership join time and the per-community read baseline.
969
+ const dmRow = await db.get<{ c: number }>(sql`
970
+ SELECT COUNT(*) AS c
971
+ FROM objects o
972
+ JOIN object_recipients orp
973
+ ON orp.object_ap_id = o.ap_id
974
+ AND orp.recipient_ap_id = ${actorApId}
975
+ AND orp.type = 'to'
976
+ LEFT JOIN dm_read_status r
977
+ ON r.conversation_id = o.conversation
978
+ AND r.actor_ap_id = ${actorApId}
979
+ WHERE o.visibility = 'direct'
980
+ AND o.type = 'Note'
981
+ AND o.conversation IS NOT NULL
982
+ AND o.attributed_to != ${actorApId}
983
+ AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
984
+ AND o.conversation NOT IN (
985
+ SELECT conversation_id FROM dm_archived_conversations
986
+ WHERE actor_ap_id = ${actorApId}
987
+ )
988
+ `);
989
+ const communityRow = await db.get<{ c: number }>(sql`
990
+ SELECT COUNT(*) AS c
991
+ FROM community_members cm
992
+ JOIN object_recipients orp
993
+ ON orp.recipient_ap_id = cm.community_ap_id
994
+ AND orp.type = 'audience'
995
+ JOIN objects o
996
+ ON o.ap_id = orp.object_ap_id
997
+ AND o.type = 'Note'
998
+ AND o.community_ap_id IS NULL
999
+ AND o.attributed_to != ${actorApId}
1000
+ LEFT JOIN dm_community_read_status r
1001
+ ON r.community_ap_id = cm.community_ap_id
1002
+ AND r.actor_ap_id = ${actorApId}
1003
+ WHERE cm.actor_ap_id = ${actorApId}
1004
+ AND o.published > COALESCE(
1005
+ r.last_read_at,
1006
+ cm.joined_at,
1007
+ '1970-01-01T00:00:00Z'
1008
+ )
1009
+ `);
1010
+ return Number(dmRow?.c ?? 0) + Number(communityRow?.c ?? 0);
1011
+ }
1012
+
1013
+ // Keep parity with the default social notification list/unread count. A
1014
+ // Yurume DM row must never inflate the Yurucommu app badge.
1015
+ const archivedCorrelation = and(
1016
+ eq(notificationArchived.actorApId, inbox.actorApId),
1017
+ eq(notificationArchived.activityApId, inbox.activityApId),
1018
+ );
1019
+ const archivedSubquery = db
1020
+ .select({ activityApId: notificationArchived.activityApId })
1021
+ .from(notificationArchived)
1022
+ .where(archivedCorrelation);
1023
+ const row = await db
1024
+ .select({ count: sql<number>`COUNT(*)` })
1025
+ .from(inbox)
1026
+ .innerJoin(activities, eq(inbox.activityApId, activities.apId))
1027
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
1028
+ .where(
1029
+ and(
1030
+ eq(inbox.actorApId, actorApId),
1031
+ eq(inbox.read, 0),
1032
+ ne(activities.actorApId, actorApId),
1033
+ inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
1034
+ or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
1035
+ notExists(archivedSubquery),
1036
+ excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
1037
+ ),
1038
+ )
1039
+ .get();
1040
+ return Number(row?.count ?? 0);
1041
+ }
1042
+
1043
+ async function finishJob(
1044
+ db: Database,
1045
+ lease: ProcessingLease,
1046
+ lastError: string | null,
1047
+ ): Promise<boolean> {
1048
+ const now = new Date().toISOString();
1049
+ const finished = await db
1050
+ .update(notificationPushJobs)
1051
+ .set({
1052
+ status: "delivered",
1053
+ processingToken: null,
1054
+ pendingPusherIdsJson: "[]",
1055
+ lastError,
1056
+ deliveredAt: now,
1057
+ updatedAt: now,
1058
+ })
1059
+ .where(processingLeaseWhere(lease));
1060
+ return affectedRowCount(finished) > 0;
1061
+ }
1062
+
1063
+ function processingLeaseWhere(lease: ProcessingLease) {
1064
+ return and(
1065
+ eq(notificationPushJobs.id, lease.jobId),
1066
+ eq(notificationPushJobs.status, "processing"),
1067
+ eq(notificationPushJobs.processingToken, lease.processingToken),
1068
+ );
1069
+ }
1070
+
1071
+ async function refreshProcessingLease(
1072
+ db: Database,
1073
+ lease: ProcessingLease,
1074
+ ): Promise<boolean> {
1075
+ const refreshed = await db
1076
+ .update(notificationPushJobs)
1077
+ .set({ updatedAt: new Date().toISOString() })
1078
+ .where(processingLeaseWhere(lease));
1079
+ return affectedRowCount(refreshed) > 0;
1080
+ }
1081
+
1082
+ function groupByGatewayAndFormat(
1083
+ pushers: StoredPusher[],
1084
+ ): Map<string, GatewayDispatchGroup> {
1085
+ const result = new Map<string, GatewayDispatchGroup>();
1086
+ for (const row of pushers) {
1087
+ const format = notificationPushFormat(parseStoredData(row.dataJson));
1088
+ const key = JSON.stringify([row.gatewayUrl, format]);
1089
+ const group = result.get(key) ?? {
1090
+ gatewayUrl: row.gatewayUrl,
1091
+ format,
1092
+ pushers: [],
1093
+ };
1094
+ group.pushers.push(row);
1095
+ result.set(key, group);
1096
+ }
1097
+ return result;
1098
+ }
1099
+
1100
+ function parsePendingIds(value: string | null): string[] | null {
1101
+ if (value === null) return null;
1102
+ try {
1103
+ const parsed = JSON.parse(value);
1104
+ return Array.isArray(parsed) &&
1105
+ parsed.every((item) => typeof item === "string")
1106
+ ? parsed
1107
+ : [];
1108
+ } catch {
1109
+ return [];
1110
+ }
1111
+ }
1112
+
1113
+ function parseStoredData(value: string): JsonObject {
1114
+ try {
1115
+ const parsed = JSON.parse(value);
1116
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1117
+ ? (parsed as JsonObject)
1118
+ : {};
1119
+ } catch {
1120
+ return {};
1121
+ }
1122
+ }
1123
+
1124
+ function notificationPushFormat(data: JsonObject): NotificationPushFormat {
1125
+ return data.format === "full" ? "full" : "event_id_only";
1126
+ }
1127
+
1128
+ function normalizedStoredData(value: string): JsonObject {
1129
+ const data = parseStoredData(value);
1130
+ return { ...data, format: notificationPushFormat(data) };
1131
+ }
1132
+
1133
+ function stringArray(value: unknown): string[] | null {
1134
+ if (value === undefined) return [];
1135
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1136
+ return null;
1137
+ }
1138
+ return value;
1139
+ }
1140
+
1141
+ function parseTimeout(value: string | undefined): number {
1142
+ const parsed = Number(value);
1143
+ if (!Number.isFinite(parsed)) return DEFAULT_GATEWAY_TIMEOUT_MS;
1144
+ return Math.max(250, Math.min(30_000, Math.floor(parsed)));
1145
+ }
1146
+
1147
+ function parseRetryAfter(value: string | null): number {
1148
+ if (!value) return 0;
1149
+ const seconds = Number(value);
1150
+ if (Number.isFinite(seconds) && seconds >= 0) {
1151
+ return Math.min(12 * 60 * 60, Math.ceil(seconds));
1152
+ }
1153
+ const timestamp = Date.parse(value);
1154
+ if (!Number.isFinite(timestamp)) return 0;
1155
+ return Math.min(
1156
+ 12 * 60 * 60,
1157
+ Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)),
1158
+ );
1159
+ }
1160
+
1161
+ async function readResponseText(
1162
+ response: Response,
1163
+ maxBytes: number,
1164
+ ): Promise<string> {
1165
+ if (!response.body) return "";
1166
+ const reader = response.body.getReader();
1167
+ const chunks: Uint8Array[] = [];
1168
+ let size = 0;
1169
+ while (true) {
1170
+ const { done, value } = await reader.read();
1171
+ if (done) break;
1172
+ size += value.byteLength;
1173
+ if (size > maxBytes) {
1174
+ await reader.cancel();
1175
+ throw new Error("gateway response is too large");
1176
+ }
1177
+ chunks.push(value);
1178
+ }
1179
+ const bytes = new Uint8Array(size);
1180
+ let offset = 0;
1181
+ for (const chunk of chunks) {
1182
+ bytes.set(chunk, offset);
1183
+ offset += chunk.byteLength;
1184
+ }
1185
+ return new TextDecoder().decode(bytes);
1186
+ }
1187
+
1188
+ function isTruthy(value: string | undefined): boolean {
1189
+ return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
1190
+ }
1191
+
1192
+ async function sha256Hex(value: string): Promise<string> {
1193
+ const digest = await crypto.subtle.digest(
1194
+ "SHA-256",
1195
+ new TextEncoder().encode(value),
1196
+ );
1197
+ return Array.from(new Uint8Array(digest), (byte) =>
1198
+ byte.toString(16).padStart(2, "0"),
1199
+ ).join("");
1200
+ }