@takosjp/yurucommu-core 3.0.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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core queue management - message builders, public enqueue entry points,
|
|
3
|
+
* and the batch handler that dispatches to sub-modules.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
|
|
7
|
+
import type { Env } from "../../types.ts";
|
|
8
|
+
import type { Database } from "../../../db/index.ts";
|
|
9
|
+
import { and, eq, notInArray, or, sql } from "drizzle-orm";
|
|
10
|
+
import { actorCache, deliveryQueue } from "../../../db/index.ts";
|
|
11
|
+
import { isSafeRemoteUrl } from "../../federation-helpers.ts";
|
|
12
|
+
import {
|
|
13
|
+
DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
14
|
+
type DeliveryDeliverEndpointMessageV1,
|
|
15
|
+
type DeliveryDlqMessageV1,
|
|
16
|
+
type DeliveryQueueMessageV1,
|
|
17
|
+
isDeliveryDlqMessageV1,
|
|
18
|
+
isDeliveryQueueMessageV1,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
import { computeDeliveryJobId, safeEndpointHost } from "./transformers.ts";
|
|
21
|
+
import { emitMetric } from "./metrics.ts";
|
|
22
|
+
import { logger } from "../logger.ts";
|
|
23
|
+
import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
|
|
24
|
+
|
|
25
|
+
const log = logger.child({ component: "delivery.queue" });
|
|
26
|
+
|
|
27
|
+
// Without DELIVERY_QUEUE/DELIVERY_DLQ bindings, enqueued activities persist in
|
|
28
|
+
// the DB but never federate. That used to be a silent no-op; surface it as a
|
|
29
|
+
// structured error/metric on first occurrence so it is observable. Reset only
|
|
30
|
+
// once a successful enqueue happens again (so a later misconfiguration re-fires).
|
|
31
|
+
let producerUnavailableReported = false;
|
|
32
|
+
|
|
33
|
+
function reportProducerUnavailable(op: string): void {
|
|
34
|
+
if (producerUnavailableReported) return;
|
|
35
|
+
producerUnavailableReported = true;
|
|
36
|
+
log.error("Delivery queue producer unavailable; activity will not federate", {
|
|
37
|
+
event: "delivery.queue.producer_unavailable",
|
|
38
|
+
op,
|
|
39
|
+
});
|
|
40
|
+
emitMetric("delivery.queue.producer_unavailable", 1, { op });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertNever(x: never): never {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Unhandled delivery queue message type: ${JSON.stringify(x)}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Concurrency primitives
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const BULKHEAD_PER_DOMAIN = 3;
|
|
54
|
+
const BULKHEAD_GLOBAL_CONCURRENCY = 10;
|
|
55
|
+
|
|
56
|
+
class Semaphore {
|
|
57
|
+
private available: number;
|
|
58
|
+
private waiters: Array<() => void> = [];
|
|
59
|
+
|
|
60
|
+
constructor(limit: number) {
|
|
61
|
+
this.available = limit;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async acquire(): Promise<void> {
|
|
65
|
+
if (this.available > 0) {
|
|
66
|
+
this.available -= 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
70
|
+
this.available -= 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
release(): void {
|
|
74
|
+
this.available += 1;
|
|
75
|
+
const next = this.waiters.shift();
|
|
76
|
+
if (next) next();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class Bulkhead {
|
|
81
|
+
private global: Semaphore;
|
|
82
|
+
private perHost = new Map<string, Semaphore>();
|
|
83
|
+
|
|
84
|
+
constructor(globalLimit: number, perHostLimit: number) {
|
|
85
|
+
this.global = new Semaphore(globalLimit);
|
|
86
|
+
this.perHostLimit = perHostLimit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private perHostLimit: number;
|
|
90
|
+
|
|
91
|
+
async acquire(host: string): Promise<void> {
|
|
92
|
+
await this.global.acquire();
|
|
93
|
+
let sem = this.perHost.get(host);
|
|
94
|
+
if (!sem) {
|
|
95
|
+
sem = new Semaphore(this.perHostLimit);
|
|
96
|
+
this.perHost.set(host, sem);
|
|
97
|
+
}
|
|
98
|
+
await sem.acquire();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
release(host: string): void {
|
|
102
|
+
const sem = this.perHost.get(host);
|
|
103
|
+
if (sem) sem.release();
|
|
104
|
+
this.global.release();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Helpers
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export function nowIso(): string {
|
|
113
|
+
return new Date().toISOString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type QueueEnv = Env & {
|
|
117
|
+
DELIVERY_QUEUE: Queue<DeliveryQueueMessageV1>;
|
|
118
|
+
DELIVERY_DLQ: Queue<DeliveryDlqMessageV1>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function queueAvailable(env: Env): env is QueueEnv {
|
|
122
|
+
return Boolean(env.DELIVERY_QUEUE) && Boolean(env.DELIVERY_DLQ);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function requireQueue(
|
|
126
|
+
env: Env,
|
|
127
|
+
label: string,
|
|
128
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
129
|
+
): env is QueueEnv {
|
|
130
|
+
if (queueAvailable(env)) return true;
|
|
131
|
+
log.warn("Missing DELIVERY_QUEUE/DELIVERY_DLQ bindings; dropping job", {
|
|
132
|
+
event: "delivery.queue.bindings_missing",
|
|
133
|
+
label,
|
|
134
|
+
});
|
|
135
|
+
message.ack();
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Queue message builders & senders
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export async function sendQueueMessage(
|
|
144
|
+
env: Env,
|
|
145
|
+
body: DeliveryQueueMessageV1,
|
|
146
|
+
delaySeconds?: number,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
if (!queueAvailable(env)) {
|
|
149
|
+
reportProducerUnavailable("sendQueueMessage");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
producerUnavailableReported = false;
|
|
153
|
+
await env.DELIVERY_QUEUE.send(
|
|
154
|
+
body,
|
|
155
|
+
delaySeconds ? { delaySeconds } : undefined,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function sendDlqMessage(
|
|
160
|
+
env: Env,
|
|
161
|
+
payload: DeliveryDlqMessageV1,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
if (!queueAvailable(env)) {
|
|
164
|
+
reportProducerUnavailable("sendDlqMessage");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
producerUnavailableReported = false;
|
|
168
|
+
await env.DELIVERY_DLQ.send(payload);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Maximum reconcile cycles before a dead-lettered job is left terminally
|
|
172
|
+
// dead_letter. Each cycle revives the job (6h apart) for one more full retry
|
|
173
|
+
// series, so this bounds a permanently-dead endpoint instead of churning the
|
|
174
|
+
// queue forever. Exported so the DLQ consumer (handleDeliveryDlqBatch) and the
|
|
175
|
+
// reconcile worker (processReconcileJob) share one source of truth.
|
|
176
|
+
export const MAX_RECONCILE_ATTEMPTS = 5;
|
|
177
|
+
|
|
178
|
+
export function buildDeliverEndpointMessage(
|
|
179
|
+
jobId: string,
|
|
180
|
+
reconcileAttempt = 0,
|
|
181
|
+
): DeliveryQueueMessageV1 {
|
|
182
|
+
return {
|
|
183
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
184
|
+
type: "deliver_endpoint",
|
|
185
|
+
jobId,
|
|
186
|
+
// Only stamp the field once a reconcile cycle has begun, so the initial
|
|
187
|
+
// delivery message stays byte-identical to before (treated as 0 on read).
|
|
188
|
+
...(reconcileAttempt > 0 ? { reconcileAttempt } : {}),
|
|
189
|
+
scheduledAt: nowIso(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function buildResolveActorMessage(
|
|
194
|
+
activityId: string,
|
|
195
|
+
recipientActorApId: string,
|
|
196
|
+
attempts = 0,
|
|
197
|
+
): DeliveryQueueMessageV1 {
|
|
198
|
+
return {
|
|
199
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
200
|
+
type: "resolve_actor",
|
|
201
|
+
activityId,
|
|
202
|
+
recipientActorApId,
|
|
203
|
+
...(attempts > 0 ? { attempts } : {}),
|
|
204
|
+
scheduledAt: nowIso(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildReconcileJobMessage(
|
|
209
|
+
jobId: string,
|
|
210
|
+
reconcileAttempt: number,
|
|
211
|
+
): DeliveryQueueMessageV1 {
|
|
212
|
+
return {
|
|
213
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
214
|
+
type: "reconcile_job",
|
|
215
|
+
jobId,
|
|
216
|
+
reconcileAttempt,
|
|
217
|
+
scheduledAt: nowIso(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Job management
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
export async function upsertDeliveryJob(
|
|
226
|
+
db: Database,
|
|
227
|
+
jobId: string,
|
|
228
|
+
activityId: string,
|
|
229
|
+
endpoint: string,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
await db
|
|
232
|
+
.insert(deliveryQueue)
|
|
233
|
+
.values({
|
|
234
|
+
id: jobId,
|
|
235
|
+
inboxUrl: endpoint,
|
|
236
|
+
activityApId: activityId,
|
|
237
|
+
attempts: 0,
|
|
238
|
+
nextAttemptAt: nowIso(),
|
|
239
|
+
status: "pending",
|
|
240
|
+
})
|
|
241
|
+
.onConflictDoNothing();
|
|
242
|
+
|
|
243
|
+
// Guard against overwriting in-flight or completed jobs.
|
|
244
|
+
await db
|
|
245
|
+
.update(deliveryQueue)
|
|
246
|
+
.set({
|
|
247
|
+
inboxUrl: endpoint,
|
|
248
|
+
activityApId: activityId,
|
|
249
|
+
})
|
|
250
|
+
.where(
|
|
251
|
+
and(
|
|
252
|
+
eq(deliveryQueue.id, jobId),
|
|
253
|
+
notInArray(deliveryQueue.status, ["processing", "delivered"]),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function enqueueResolveForEndpointActors(
|
|
259
|
+
db: Database,
|
|
260
|
+
env: Env,
|
|
261
|
+
activityId: string,
|
|
262
|
+
endpoint: string,
|
|
263
|
+
): Promise<number> {
|
|
264
|
+
if (!queueAvailable(env)) return 0;
|
|
265
|
+
|
|
266
|
+
const PAGE_SIZE = 200;
|
|
267
|
+
const SEND_BATCH_SIZE = 100;
|
|
268
|
+
const MAX_ACTORS = 2000;
|
|
269
|
+
|
|
270
|
+
let cursor: string | null = null;
|
|
271
|
+
let enqueued = 0;
|
|
272
|
+
|
|
273
|
+
while (enqueued < MAX_ACTORS) {
|
|
274
|
+
let query = db
|
|
275
|
+
.select({ apId: actorCache.apId })
|
|
276
|
+
.from(actorCache)
|
|
277
|
+
.where(
|
|
278
|
+
or(
|
|
279
|
+
eq(actorCache.sharedInbox, endpoint),
|
|
280
|
+
eq(actorCache.inbox, endpoint),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
.orderBy(actorCache.apId)
|
|
284
|
+
.limit(PAGE_SIZE);
|
|
285
|
+
|
|
286
|
+
if (cursor) {
|
|
287
|
+
query = db
|
|
288
|
+
.select({ apId: actorCache.apId })
|
|
289
|
+
.from(actorCache)
|
|
290
|
+
.where(
|
|
291
|
+
and(
|
|
292
|
+
or(
|
|
293
|
+
eq(actorCache.sharedInbox, endpoint),
|
|
294
|
+
eq(actorCache.inbox, endpoint),
|
|
295
|
+
),
|
|
296
|
+
sql`${actorCache.apId} > ${cursor}`,
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
.orderBy(actorCache.apId)
|
|
300
|
+
.limit(PAGE_SIZE);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const page = await query;
|
|
304
|
+
|
|
305
|
+
if (page.length === 0) break;
|
|
306
|
+
|
|
307
|
+
for (
|
|
308
|
+
let i = 0;
|
|
309
|
+
i < page.length && enqueued < MAX_ACTORS;
|
|
310
|
+
i += SEND_BATCH_SIZE
|
|
311
|
+
) {
|
|
312
|
+
const candidates = page
|
|
313
|
+
.slice(i, i + SEND_BATCH_SIZE)
|
|
314
|
+
.map((r) => r.apId)
|
|
315
|
+
.filter((apId) => isSafeRemoteUrl(apId));
|
|
316
|
+
|
|
317
|
+
// Drop recipients the operator has defederated before re-enqueueing
|
|
318
|
+
// resolve_actor jobs (outbound blocklist enforcement). Batched (2 queries)
|
|
319
|
+
// rather than a serial isActorBlocked per candidate.
|
|
320
|
+
const blockedSet = await filterBlockedActorApIds(db, candidates);
|
|
321
|
+
const slice = candidates.filter((apId) => !blockedSet.has(apId));
|
|
322
|
+
|
|
323
|
+
if (slice.length === 0) continue;
|
|
324
|
+
|
|
325
|
+
const requests = slice.map((recipientApId) => ({
|
|
326
|
+
body: buildResolveActorMessage(activityId, recipientApId),
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
await env.DELIVERY_QUEUE.sendBatch(requests);
|
|
330
|
+
enqueued += slice.length;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
cursor = page[page.length - 1]?.apId ?? null;
|
|
334
|
+
if (page.length < PAGE_SIZE) break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (enqueued >= MAX_ACTORS) {
|
|
338
|
+
log.warn(
|
|
339
|
+
"Endpoint invalidation affected many actors; capped re-resolution enqueue",
|
|
340
|
+
{
|
|
341
|
+
event: "delivery.queue.reresolution_capped",
|
|
342
|
+
endpoint,
|
|
343
|
+
activityId,
|
|
344
|
+
enqueued,
|
|
345
|
+
max: MAX_ACTORS,
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return enqueued;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Public enqueue entry points
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
export async function enqueueFanoutToFollowers(
|
|
358
|
+
env: Env,
|
|
359
|
+
activityId: string,
|
|
360
|
+
followeeApId: string,
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
await sendQueueMessage(env, {
|
|
363
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
364
|
+
type: "fanout_followers",
|
|
365
|
+
activityId,
|
|
366
|
+
followeeApId,
|
|
367
|
+
scheduledAt: nowIso(),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function enqueueDeliveryToActor(
|
|
372
|
+
env: Env,
|
|
373
|
+
activityId: string,
|
|
374
|
+
recipientActorApId: string,
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
// Enforce the operator blocklist on the OUTBOUND single-actor path (DMs,
|
|
377
|
+
// Accept/Follow responses, targeted post/story interactions). A defederated
|
|
378
|
+
// domain/actor must never receive our activities, mirroring the inbound
|
|
379
|
+
// enforcement in the inbox handler. Best-effort: if the db read fails,
|
|
380
|
+
// isActorBlocked returns false (never black-hole on a transient error).
|
|
381
|
+
const db = (env as Partial<Env>).DB_INSTANCE;
|
|
382
|
+
if (db && (await isActorBlocked(db, recipientActorApId))) {
|
|
383
|
+
log.info("Skipping outbound delivery to blocked actor", {
|
|
384
|
+
event: "delivery.blocklist.actor_skip",
|
|
385
|
+
actor: recipientActorApId,
|
|
386
|
+
activityId,
|
|
387
|
+
});
|
|
388
|
+
emitMetric("delivery.blocklist.actor_skip", 1, {});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await sendQueueMessage(
|
|
393
|
+
env,
|
|
394
|
+
buildResolveActorMessage(activityId, recipientActorApId),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Fan an activity out to a community's audience (members + community
|
|
400
|
+
* followers) instead of the author's personal follower graph. Used for
|
|
401
|
+
* community-scoped posts so reach == community.
|
|
402
|
+
*/
|
|
403
|
+
export async function enqueueFanoutToCommunity(
|
|
404
|
+
env: Env,
|
|
405
|
+
activityId: string,
|
|
406
|
+
communityApId: string,
|
|
407
|
+
announceActivityId?: string,
|
|
408
|
+
): Promise<void> {
|
|
409
|
+
await sendQueueMessage(env, {
|
|
410
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
411
|
+
type: "fanout_community",
|
|
412
|
+
activityId,
|
|
413
|
+
communityApId,
|
|
414
|
+
...(announceActivityId ? { announceActivityId } : {}),
|
|
415
|
+
scheduledAt: nowIso(),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Batch handlers (top-level entry points for queue consumers)
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
export async function handleDeliveryQueueBatch(
|
|
424
|
+
batch: MessageBatch<DeliveryQueueMessageV1>,
|
|
425
|
+
env: Env,
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
const db = env.DB_INSTANCE;
|
|
428
|
+
const bulkhead = new Bulkhead(
|
|
429
|
+
BULKHEAD_GLOBAL_CONCURRENCY,
|
|
430
|
+
BULKHEAD_PER_DOMAIN,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// Lazy import sub-modules to avoid circular dependencies at module level
|
|
434
|
+
const {
|
|
435
|
+
processFanoutFollowers,
|
|
436
|
+
processFanoutCommunity,
|
|
437
|
+
processResolveActor,
|
|
438
|
+
processReconcileJob,
|
|
439
|
+
runWithConcurrency,
|
|
440
|
+
} = await import("./queue-batching.ts");
|
|
441
|
+
const { processDeliverEndpoint } = await import("./queue-delivery.ts");
|
|
442
|
+
|
|
443
|
+
// Process non-delivery messages first (planning/resolution).
|
|
444
|
+
for (const message of batch.messages) {
|
|
445
|
+
const body = message.body;
|
|
446
|
+
if (!isDeliveryQueueMessageV1(body)) {
|
|
447
|
+
log.warn("Invalid delivery message format, skipping", {
|
|
448
|
+
event: "delivery.queue.invalid_message",
|
|
449
|
+
bodyPreview: JSON.stringify(body).slice(0, 200),
|
|
450
|
+
});
|
|
451
|
+
message.ack();
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (body.type === "deliver_endpoint") {
|
|
456
|
+
// handled later with concurrency
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
switch (body.type) {
|
|
462
|
+
case "fanout_followers":
|
|
463
|
+
await processFanoutFollowers(db, env, body, message);
|
|
464
|
+
break;
|
|
465
|
+
case "fanout_community":
|
|
466
|
+
await processFanoutCommunity(db, env, body, message);
|
|
467
|
+
break;
|
|
468
|
+
case "resolve_actor":
|
|
469
|
+
await processResolveActor(db, env, body, message);
|
|
470
|
+
break;
|
|
471
|
+
case "reconcile_job":
|
|
472
|
+
await processReconcileJob(db, env, body, message);
|
|
473
|
+
break;
|
|
474
|
+
default:
|
|
475
|
+
assertNever(body);
|
|
476
|
+
}
|
|
477
|
+
} catch (e) {
|
|
478
|
+
log.error("Non-delivery message failed", {
|
|
479
|
+
event: "delivery.queue.non_delivery_failed",
|
|
480
|
+
messageType: body.type,
|
|
481
|
+
error: e,
|
|
482
|
+
});
|
|
483
|
+
message.retry({ delaySeconds: 60 });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Deliver endpoint messages with bulkhead+concurrency.
|
|
488
|
+
const deliveryMessages = batch.messages.filter(
|
|
489
|
+
(m: Message<DeliveryQueueMessageV1>) =>
|
|
490
|
+
isDeliveryQueueMessageV1(m.body) && m.body.type === "deliver_endpoint",
|
|
491
|
+
) as Array<Message<DeliveryQueueMessageV1>>;
|
|
492
|
+
await runWithConcurrency(
|
|
493
|
+
deliveryMessages,
|
|
494
|
+
BULKHEAD_GLOBAL_CONCURRENCY,
|
|
495
|
+
async (m: Message<DeliveryQueueMessageV1>) => {
|
|
496
|
+
try {
|
|
497
|
+
await processDeliverEndpoint(
|
|
498
|
+
db,
|
|
499
|
+
env,
|
|
500
|
+
m.body as DeliveryDeliverEndpointMessageV1,
|
|
501
|
+
m,
|
|
502
|
+
bulkhead,
|
|
503
|
+
);
|
|
504
|
+
} catch (e) {
|
|
505
|
+
const body = m.body as DeliveryDeliverEndpointMessageV1;
|
|
506
|
+
log.error("deliver_endpoint failed", {
|
|
507
|
+
event: "delivery.queue.deliver_endpoint_failed",
|
|
508
|
+
jobId: body?.jobId,
|
|
509
|
+
error: e,
|
|
510
|
+
});
|
|
511
|
+
m.retry({ delaySeconds: 60 });
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function handleDeliveryDlqBatch(
|
|
518
|
+
batch: MessageBatch<DeliveryDlqMessageV1>,
|
|
519
|
+
env: Env,
|
|
520
|
+
): Promise<void> {
|
|
521
|
+
for (const message of batch.messages) {
|
|
522
|
+
const body = message.body;
|
|
523
|
+
if (!isDeliveryDlqMessageV1(body)) {
|
|
524
|
+
log.warn("Invalid DLQ message format, skipping", {
|
|
525
|
+
event: "delivery.dlq.invalid_message",
|
|
526
|
+
bodyPreview: JSON.stringify(body).slice(0, 200),
|
|
527
|
+
});
|
|
528
|
+
message.ack();
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Structured log for alerting/monitoring.
|
|
533
|
+
log.error("Delivery job dead-lettered", {
|
|
534
|
+
event: "delivery.dlq.job_dead_lettered",
|
|
535
|
+
jobId: body.jobId,
|
|
536
|
+
activityId: body.activityId,
|
|
537
|
+
endpoint: body.endpoint,
|
|
538
|
+
attempts: body.attempts,
|
|
539
|
+
lastError: body.lastError,
|
|
540
|
+
deadLetteredAt: body.deadLetteredAt,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Phase 3: periodic reconciliation (best-effort), BOUNDED. The job's
|
|
544
|
+
// reconcile-cycle count is carried in-band on the dead-lettered
|
|
545
|
+
// deliver_endpoint message (default 0 for the first dead-letter). Once it
|
|
546
|
+
// reaches the cap, stop reconciling and leave the job terminally dead_letter
|
|
547
|
+
// — otherwise a permanently-dead endpoint loops dead_letter -> reconcile ->
|
|
548
|
+
// dead_letter forever. The next cycle carries count+1.
|
|
549
|
+
const reconcileAttempt = body.reconcileAttempt ?? 0;
|
|
550
|
+
if (reconcileAttempt >= MAX_RECONCILE_ATTEMPTS) {
|
|
551
|
+
log.warn("Delivery job exhausted reconciliation budget; giving up", {
|
|
552
|
+
event: "delivery.dlq.reconciliation_exhausted",
|
|
553
|
+
jobId: body.jobId,
|
|
554
|
+
endpoint: body.endpoint,
|
|
555
|
+
reconcileAttempt,
|
|
556
|
+
});
|
|
557
|
+
message.ack();
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
await sendQueueMessage(
|
|
562
|
+
env,
|
|
563
|
+
buildReconcileJobMessage(body.jobId, reconcileAttempt + 1),
|
|
564
|
+
6 * 60 * 60,
|
|
565
|
+
);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
log.warn("Failed to schedule DLQ reconciliation", {
|
|
568
|
+
event: "delivery.dlq.reconciliation_schedule_failed",
|
|
569
|
+
jobId: body.jobId,
|
|
570
|
+
error: e,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
message.ack();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isSafeRemoteUrl } from "../../federation-helpers.ts";
|
|
2
|
+
import { bytesToHex } from "../hex.ts";
|
|
3
|
+
|
|
4
|
+
export const DELIVERY_ENDPOINT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
// 1m, 2m, 4m, 8m, 16m, 32m, 64m, 128m (contract)
|
|
7
|
+
const BACKOFF_SERIES_SECONDS = [
|
|
8
|
+
60, 120, 240, 480, 960, 1920, 3840, 7680,
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export const DELIVERY_MAX_ATTEMPTS = BACKOFF_SERIES_SECONDS.length;
|
|
12
|
+
|
|
13
|
+
export async function sha256Hex(input: string): Promise<string> {
|
|
14
|
+
const data = new TextEncoder().encode(input);
|
|
15
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
16
|
+
return bytesToHex(new Uint8Array(digest));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function computeDeliveryJobId(
|
|
20
|
+
activityId: string,
|
|
21
|
+
endpoint: string,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
// Deterministic idempotency key: activityId + endpoint (+ attemptGroup if needed).
|
|
24
|
+
return sha256Hex(`${activityId}|${endpoint}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function computeRetryDelaySeconds(nextAttempt: number): number {
|
|
28
|
+
// nextAttempt is 1-based: first retry => 1.
|
|
29
|
+
const idx = Math.max(
|
|
30
|
+
0,
|
|
31
|
+
Math.min(nextAttempt - 1, BACKOFF_SERIES_SECONDS.length - 1),
|
|
32
|
+
);
|
|
33
|
+
const base = BACKOFF_SERIES_SECONDS[idx];
|
|
34
|
+
// jitter: +/- 20%
|
|
35
|
+
const jitterFactor = 0.8 + Math.random() * 0.4;
|
|
36
|
+
return Math.max(1, Math.round(base * jitterFactor));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function safeParseIsoTimeMs(
|
|
40
|
+
value: string | null | undefined,
|
|
41
|
+
): number | null {
|
|
42
|
+
if (!value) return null;
|
|
43
|
+
const ms = Date.parse(value);
|
|
44
|
+
return Number.isFinite(ms) ? ms : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function safeEndpointHost(endpoint: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(endpoint);
|
|
50
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
51
|
+
if (!isSafeRemoteUrl(endpoint)) return null;
|
|
52
|
+
return url.host;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|