@takosjp/yurucommu-core 3.0.3 → 3.2.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 (40) hide show
  1. package/README.en.md +92 -0
  2. package/README.md +56 -47
  3. package/migrations/0019_notification_push_delivery.sql +103 -0
  4. package/migrations/README.md +7 -6
  5. package/package.json +11 -5
  6. package/packages/api/package.json +1 -1
  7. package/packages/api/src/lib/api/browser-push.ts +545 -0
  8. package/packages/api/src/lib/api/communities.ts +25 -2
  9. package/packages/api/src/lib/api/dm.ts +14 -2
  10. package/packages/api/src/lib/api/normalize.ts +15 -4
  11. package/packages/api/src/lib/api/notification-target.ts +106 -0
  12. package/packages/api/src/lib/api/notifications.ts +53 -1
  13. package/packages/api/src/lib/api/push-config.ts +132 -0
  14. package/packages/api/src/lib/api.ts +3 -0
  15. package/packages/api/src/social-server.ts +9 -0
  16. package/packages/api/src/types/index.ts +48 -0
  17. package/src/backend/index.ts +67 -3
  18. package/src/backend/lib/attachments.ts +52 -0
  19. package/src/backend/lib/delivery/queue.ts +73 -0
  20. package/src/backend/lib/delivery/types.ts +15 -1
  21. package/src/backend/lib/notification-eligibility.ts +150 -0
  22. package/src/backend/lib/notification-push.ts +1213 -0
  23. package/src/backend/lib/notification-pusher-contract.ts +340 -0
  24. package/src/backend/lib/oauth-providers.ts +7 -6
  25. package/src/backend/lib/session-actor.ts +16 -1
  26. package/src/backend/lib/unread-counts.ts +79 -0
  27. package/src/backend/middleware/csrf.ts +11 -0
  28. package/src/backend/routes/account-teardown.ts +13 -0
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +124 -2
  31. package/src/backend/routes/communities/messages.ts +123 -9
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +51 -4
  34. package/src/backend/routes/notification-pushers.ts +93 -0
  35. package/src/backend/routes/notifications.ts +76 -69
  36. package/src/backend/routes/posts/transformers.ts +8 -10
  37. package/src/backend/server.ts +6 -0
  38. package/src/backend/types.ts +12 -0
  39. package/src/db/index.ts +11 -10
  40. package/src/db/schema/mobile.ts +99 -1
@@ -21,6 +21,11 @@ import { computeDeliveryJobId, safeEndpointHost } from "./transformers.ts";
21
21
  import { emitMetric } from "./metrics.ts";
22
22
  import { logger } from "../logger.ts";
23
23
  import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
24
+ import {
25
+ enqueuePendingNotificationPushJobs,
26
+ processNotificationPushJob,
27
+ recoverDeadLetteredNotificationPushJob,
28
+ } from "../notification-push.ts";
24
29
 
25
30
  const log = logger.child({ component: "delivery.queue" });
26
31
 
@@ -471,6 +476,9 @@ export async function handleDeliveryQueueBatch(
471
476
  case "reconcile_job":
472
477
  await processReconcileJob(db, env, body, message);
473
478
  break;
479
+ case "notification_push":
480
+ await processNotificationPushJob(env, body, message);
481
+ break;
474
482
  default:
475
483
  assertNever(body);
476
484
  }
@@ -512,6 +520,17 @@ export async function handleDeliveryQueueBatch(
512
520
  }
513
521
  },
514
522
  );
523
+
524
+ // Community fanout can create local inbox rows inside this consumer rather
525
+ // than an HTTP request. Flush the same DB-triggered outbox choke point here.
526
+ try {
527
+ await enqueuePendingNotificationPushJobs(env);
528
+ } catch (error) {
529
+ log.error("Failed to enqueue notification push outbox", {
530
+ event: "notification.push.enqueue_failed",
531
+ error,
532
+ });
533
+ }
515
534
  }
516
535
 
517
536
  export async function handleDeliveryDlqBatch(
@@ -521,10 +540,21 @@ export async function handleDeliveryDlqBatch(
521
540
  for (const message of batch.messages) {
522
541
  const body = message.body;
523
542
  if (!isDeliveryDlqMessageV1(body)) {
543
+ // Not an app-built `dlq` message. Cloudflare Queues also delivers here
544
+ // the RAW original body of any MAIN-queue message that exhausted its
545
+ // retries (automatic dead-lettering). Those must NOT be silently acked as
546
+ // "invalid": a lost fanout/resolve drops local notifications or delivery
547
+ // planning, and a lost notification_push strands its durable outbox row.
548
+ if (isDeliveryQueueMessageV1(body)) {
549
+ await handleAutoDeadLetteredMessage(env, body);
550
+ message.ack();
551
+ continue;
552
+ }
524
553
  log.warn("Invalid DLQ message format, skipping", {
525
554
  event: "delivery.dlq.invalid_message",
526
555
  bodyPreview: JSON.stringify(body).slice(0, 200),
527
556
  });
557
+ emitMetric("delivery.dlq.invalid_message", 1, {});
528
558
  message.ack();
529
559
  continue;
530
560
  }
@@ -574,3 +604,46 @@ export async function handleDeliveryDlqBatch(
574
604
  message.ack();
575
605
  }
576
606
  }
607
+
608
+ /**
609
+ * Recover / account for a MAIN-queue message that Cloudflare auto-dead-lettered
610
+ * (retries exhausted with the raw body). `notification_push` rows are durable,
611
+ * so reset the job to retry through the outbox instead of stranding it;
612
+ * everything else is logged with an alerting metric rather than swallowed.
613
+ */
614
+ async function handleAutoDeadLetteredMessage(
615
+ env: Env,
616
+ body: DeliveryQueueMessageV1,
617
+ ): Promise<void> {
618
+ if (body.type === "notification_push") {
619
+ try {
620
+ const recovered = await recoverDeadLetteredNotificationPushJob(
621
+ env.DB_INSTANCE,
622
+ body.jobId,
623
+ );
624
+ log.error("notification_push dead-lettered; reset durable outbox row", {
625
+ event: "delivery.dlq.notification_push_recovered",
626
+ jobId: body.jobId,
627
+ recovered,
628
+ });
629
+ emitMetric("delivery.dlq.notification_push_recovered", 1, {});
630
+ } catch (error) {
631
+ log.error("Failed to recover dead-lettered notification_push", {
632
+ event: "delivery.dlq.notification_push_recover_failed",
633
+ jobId: body.jobId,
634
+ error,
635
+ });
636
+ emitMetric("delivery.dlq.notification_push_recover_failed", 1, {});
637
+ }
638
+ return;
639
+ }
640
+
641
+ // fanout_*/resolve_actor/deliver_endpoint/reconcile_job: no durable ledger to
642
+ // rewind here, but make the loss explicit (alertable) rather than a silent
643
+ // "invalid message" ack.
644
+ log.error("Delivery message auto-dead-lettered; dropping", {
645
+ event: "delivery.dlq.auto_dead_lettered",
646
+ messageType: body.type,
647
+ });
648
+ emitMetric("delivery.dlq.auto_dead_lettered", 1, { message_type: body.type });
649
+ }
@@ -60,12 +60,24 @@ export type DeliveryReconcileJobMessageV1 = {
60
60
  scheduledAt: string; // ISO8601 UTC
61
61
  };
62
62
 
63
+ /**
64
+ * Product notification delivery. The queue carries only the durable outbox id;
65
+ * device pushkeys, gateway URLs, and notification content remain in the DB.
66
+ */
67
+ export type DeliveryNotificationPushMessageV1 = {
68
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
69
+ type: "notification_push";
70
+ jobId: string;
71
+ scheduledAt: string; // ISO8601 UTC
72
+ };
73
+
63
74
  export type DeliveryQueueMessageV1 =
64
75
  | DeliveryFanoutFollowersMessageV1
65
76
  | DeliveryFanoutCommunityMessageV1
66
77
  | DeliveryResolveActorMessageV1
67
78
  | DeliveryDeliverEndpointMessageV1
68
- | DeliveryReconcileJobMessageV1;
79
+ | DeliveryReconcileJobMessageV1
80
+ | DeliveryNotificationPushMessageV1;
69
81
 
70
82
  export type DeliveryDlqMessageV1 = {
71
83
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
@@ -116,6 +128,8 @@ export function isDeliveryQueueMessageV1(
116
128
  typeof v.reconcileAttempt === "number" &&
117
129
  typeof v.scheduledAt === "string"
118
130
  );
131
+ case "notification_push":
132
+ return typeof v.jobId === "string" && typeof v.scheduledAt === "string";
119
133
  default:
120
134
  return false;
121
135
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared social-notification eligibility predicates.
3
+ *
4
+ * SINGLE owner of the WHERE fragments that decide whether an inbox row is a
5
+ * user-facing notification. Consumed by ALL of:
6
+ * - GET /api/notifications (list),
7
+ * - GET /api/notifications/unread/count (badge),
8
+ * - the push outbox processor (delivery-time re-check + push unread badge)
9
+ * so a change to eligibility (a new activity type, a new suppression rule)
10
+ * cannot silently drift between the list, the badge, and push delivery — the
11
+ * dispersion that past audits repeatedly flagged for visibility logic.
12
+ */
13
+
14
+ import {
15
+ and,
16
+ eq,
17
+ exists,
18
+ inArray,
19
+ isNull,
20
+ ne,
21
+ notExists,
22
+ or,
23
+ type SQL,
24
+ } from "drizzle-orm";
25
+
26
+ import {
27
+ activities,
28
+ dmArchivedConversations,
29
+ inbox,
30
+ notificationArchived,
31
+ objects,
32
+ type Database,
33
+ } from "../../db/index.ts";
34
+ import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
35
+
36
+ /**
37
+ * The activity types that surface as notifications. Any addition must hold for
38
+ * the list, the unread badge, AND push delivery — they all read this constant.
39
+ */
40
+ export const NOTIFICATION_ACTIVITY_TYPES = [
41
+ "Follow",
42
+ "Like",
43
+ "Announce",
44
+ "Create",
45
+ ] as const;
46
+
47
+ export interface NotificationEligibilityOptions {
48
+ /**
49
+ * How direct-visibility (DM) Creates are treated:
50
+ * - "exclude" (list/badge): a DM surfaces in the DM view, never as a
51
+ * notification row, so drop it entirely.
52
+ * - "unless-dm-archived" (push): a direct Create IS push-eligible (routed to
53
+ * Yurume), unless the recipient archived the conversation — archiving is a
54
+ * delivery preference, not merely a presentation filter.
55
+ */
56
+ readonly direct: "exclude" | "unless-dm-archived";
57
+ /**
58
+ * Archive partition. "exclude" (default: badge/push and the default list
59
+ * view) hides archived rows; "only" (the list's archived view) shows ONLY
60
+ * archived rows.
61
+ */
62
+ readonly archived?: "exclude" | "only";
63
+ /**
64
+ * Restrict to a subset of NOTIFICATION_ACTIVITY_TYPES (the list's type
65
+ * filter). Defaults to the full set; callers must not widen it.
66
+ */
67
+ readonly activityTypes?: readonly string[];
68
+ }
69
+
70
+ /**
71
+ * Eligibility conditions for a query shaped as
72
+ * `inbox JOIN activities LEFT JOIN objects` scoped to `actorApId`'s inbox.
73
+ * Returns the shared conditions only; callers add their own paging/read/type
74
+ * filters on top.
75
+ */
76
+ export function notificationEligibilityWhere(
77
+ db: Database,
78
+ actorApId: string,
79
+ options: NotificationEligibilityOptions,
80
+ ): SQL[] {
81
+ // Never notify an actor about their own activity.
82
+ const notSelf = ne(activities.actorApId, actorApId);
83
+
84
+ // Only user-facing activity types.
85
+ const userFacingType = inArray(activities.type, [
86
+ ...(options.activityTypes ?? NOTIFICATION_ACTIVITY_TYPES),
87
+ ]);
88
+
89
+ // Archive partition. Archived notifications are hidden from the default
90
+ // list, excluded from the badge, and must not push; the list's archived view
91
+ // inverts the predicate.
92
+ const archivedSubquery = db
93
+ .select({ activityApId: notificationArchived.activityApId })
94
+ .from(notificationArchived)
95
+ .where(
96
+ and(
97
+ eq(notificationArchived.actorApId, inbox.actorApId),
98
+ eq(notificationArchived.activityApId, inbox.activityApId),
99
+ ),
100
+ );
101
+ const notArchived =
102
+ options.archived === "only"
103
+ ? exists(archivedSubquery)
104
+ : notExists(archivedSubquery);
105
+
106
+ // Direct (DM) handling. The object join is LEFT (Follow's object is an
107
+ // actor, not an objects row), so NULL visibility must be kept.
108
+ let directCondition: SQL;
109
+ if (options.direct === "exclude") {
110
+ directCondition = or(
111
+ isNull(objects.visibility),
112
+ ne(objects.visibility, "direct"),
113
+ )!;
114
+ } else {
115
+ const archivedDmSubquery = db
116
+ .select({ conversationId: dmArchivedConversations.conversationId })
117
+ .from(dmArchivedConversations)
118
+ .where(
119
+ and(
120
+ eq(dmArchivedConversations.actorApId, actorApId),
121
+ eq(dmArchivedConversations.conversationId, objects.conversation),
122
+ ),
123
+ );
124
+ directCondition = or(
125
+ isNull(objects.visibility),
126
+ ne(objects.visibility, "direct"),
127
+ notExists(archivedDmSubquery),
128
+ )!;
129
+ }
130
+
131
+ const conditions: SQL[] = [
132
+ notSelf,
133
+ userFacingType,
134
+ notArchived,
135
+ directCondition,
136
+ ];
137
+
138
+ // Suppress notifications whose actor the recipient has blocked or muted.
139
+ // This is the read-time choke point: mutes are read-only everywhere, and not
140
+ // every notify WRITE path block-checks, so gating here covers like/repost/
141
+ // follow/reply/mention (local AND federated) for both blocks and mutes.
142
+ const blockMute = excludeBlockedMutedAuthors(
143
+ db,
144
+ actorApId,
145
+ activities.actorApId,
146
+ );
147
+ if (blockMute) conditions.push(blockMute);
148
+
149
+ return conditions;
150
+ }