@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.
- package/README.en.md +92 -0
- package/README.md +56 -47
- package/migrations/0019_notification_push_delivery.sql +103 -0
- package/migrations/README.md +7 -6
- package/package.json +11 -5
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +545 -0
- package/packages/api/src/lib/api/communities.ts +25 -2
- package/packages/api/src/lib/api/dm.ts +14 -2
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/notifications.ts +53 -1
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +3 -0
- package/packages/api/src/social-server.ts +9 -0
- package/packages/api/src/types/index.ts +48 -0
- package/src/backend/index.ts +67 -3
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +73 -0
- package/src/backend/lib/delivery/types.ts +15 -1
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +1213 -0
- package/src/backend/lib/notification-pusher-contract.ts +340 -0
- package/src/backend/lib/oauth-providers.ts +7 -6
- package/src/backend/lib/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +124 -2
- package/src/backend/routes/communities/messages.ts +123 -9
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +51 -4
- package/src/backend/routes/notification-pushers.ts +93 -0
- package/src/backend/routes/notifications.ts +76 -69
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/index.ts +11 -10
- 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
|
+
}
|