@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,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery endpoint processing - handles the actual HTTP delivery of ActivityPub activities.
|
|
3
|
+
* Includes signing, circuit breaker checks, retry logic, and dead-letter handling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Message } from "@cloudflare/workers-types";
|
|
7
|
+
import type { Env } from "../../types.ts";
|
|
8
|
+
import type { Database } from "../../../db/index.ts";
|
|
9
|
+
import { and, eq, lt, notInArray, or, sql } from "drizzle-orm";
|
|
10
|
+
import {
|
|
11
|
+
activities,
|
|
12
|
+
actorCache,
|
|
13
|
+
actors,
|
|
14
|
+
communities,
|
|
15
|
+
deliveryQueue,
|
|
16
|
+
instanceActor,
|
|
17
|
+
} from "../../../db/index.ts";
|
|
18
|
+
import {
|
|
19
|
+
fetchWithTimeout,
|
|
20
|
+
isSafeRemoteUrl,
|
|
21
|
+
signRequest,
|
|
22
|
+
} from "../../federation-helpers.ts";
|
|
23
|
+
import { emitMetric } from "./metrics.ts";
|
|
24
|
+
import { logger } from "../logger.ts";
|
|
25
|
+
import {
|
|
26
|
+
checkCircuit,
|
|
27
|
+
recordCircuitFailure,
|
|
28
|
+
recordCircuitSuccess,
|
|
29
|
+
} from "./circuit.ts";
|
|
30
|
+
|
|
31
|
+
const log = logger.child({ component: "delivery.queue" });
|
|
32
|
+
import {
|
|
33
|
+
type DeliveryDeliverEndpointMessageV1,
|
|
34
|
+
type DeliveryQueueMessageV1,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
import {
|
|
37
|
+
computeRetryDelaySeconds,
|
|
38
|
+
DELIVERY_MAX_ATTEMPTS,
|
|
39
|
+
safeEndpointHost,
|
|
40
|
+
safeParseIsoTimeMs,
|
|
41
|
+
} from "./transformers.ts";
|
|
42
|
+
import {
|
|
43
|
+
buildDeliverEndpointMessage,
|
|
44
|
+
type Bulkhead,
|
|
45
|
+
enqueueResolveForEndpointActors,
|
|
46
|
+
nowIso,
|
|
47
|
+
type QueueEnv,
|
|
48
|
+
requireQueue,
|
|
49
|
+
sendDlqMessage,
|
|
50
|
+
sendQueueMessage,
|
|
51
|
+
} from "./queue.ts";
|
|
52
|
+
|
|
53
|
+
const DELIVERY_HTTP_TIMEOUT_MS = 8000;
|
|
54
|
+
const STALE_PROCESSING_MS = 2 * 60 * 1000;
|
|
55
|
+
const EPOCH_ISO = "1970-01-01T00:00:00.000Z";
|
|
56
|
+
|
|
57
|
+
// 4xx statuses that are RETRYABLE rather than permanent (see isPermanentDeliveryFailure):
|
|
58
|
+
// 429 Too Many Requests, 408 Request Timeout, 425 Too Early.
|
|
59
|
+
// 401 Unauthorized: in secure-mode / authorized-fetch this is the remote FAILING
|
|
60
|
+
// to verify our HTTP signature, which is frequently TRANSIENT — it recovers
|
|
61
|
+
// once the remote (re-)fetches our actor #main-key (our origin was briefly
|
|
62
|
+
// down, mid key-rotation, the remote's key cache raced) or a brief Date/clock
|
|
63
|
+
// skew passes. A job is keyed by the SHARED INBOX endpoint, so treating this
|
|
64
|
+
// as permanent black-holed the activity for EVERY co-tenant recipient.
|
|
65
|
+
// 404 Not Found: a shared inbox returning 404 is usually a transient blip
|
|
66
|
+
// (deploy/restart/misroute). It also triggers endpoint re-resolution, and
|
|
67
|
+
// failing permanently here left the re-resolved job colliding on the same
|
|
68
|
+
// terminal jobId (silently dropped). Retrying lets the endpoint recover.
|
|
69
|
+
// 410 Gone (endpoint genuinely removed) and 400/403/422 (a remote per-activity /
|
|
70
|
+
// deliberate-relationship verdict, not endpoint health) stay PERMANENT.
|
|
71
|
+
export const TRANSIENT_DELIVERY_4XX: ReadonlySet<number> = new Set([
|
|
72
|
+
401, 404, 408, 425, 429,
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A delivery response status that should be PERMANENTLY failed (no retry): a 4xx
|
|
77
|
+
* that is not in the transient set. 5xx and transient 4xx are retried; a null
|
|
78
|
+
* status (network error) is not classified here.
|
|
79
|
+
*/
|
|
80
|
+
export function isPermanentDeliveryFailure(status: number | null): boolean {
|
|
81
|
+
return (
|
|
82
|
+
status !== null &&
|
|
83
|
+
status >= 400 &&
|
|
84
|
+
status < 500 &&
|
|
85
|
+
!TRANSIENT_DELIVERY_4XX.has(status)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type TimedFetchResult = {
|
|
90
|
+
response: Response | null;
|
|
91
|
+
error: unknown;
|
|
92
|
+
latencyMs: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async function timedFetch(
|
|
96
|
+
url: string,
|
|
97
|
+
init: RequestInit & { timeout: number },
|
|
98
|
+
): Promise<TimedFetchResult> {
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
let response: Response | null = null;
|
|
101
|
+
let error: unknown = null;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetchWithTimeout(url, init);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
error = e;
|
|
106
|
+
}
|
|
107
|
+
return { response, error, latencyMs: Date.now() - startedAt };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildErrorMessage(response: Response | null, error: unknown): string {
|
|
111
|
+
if (response) return `HTTP ${response.status}`;
|
|
112
|
+
if (error instanceof Error) return error.message;
|
|
113
|
+
return "delivery_error";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseCommaSeparated(value: string | undefined): string[] {
|
|
117
|
+
if (!value) return [];
|
|
118
|
+
return value
|
|
119
|
+
.split(",")
|
|
120
|
+
.map((v) => v.trim())
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseSampleRate(value: string | undefined): number {
|
|
125
|
+
if (!value) return 1.0;
|
|
126
|
+
const n = Number(value);
|
|
127
|
+
if (!Number.isFinite(n)) return 1.0;
|
|
128
|
+
return Math.max(0, Math.min(1, n));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolveSigningActor(
|
|
132
|
+
db: Database,
|
|
133
|
+
actorApId: string,
|
|
134
|
+
): Promise<{ apId: string; privateKeyPem: string } | null> {
|
|
135
|
+
// Check actors table. This MUST resolve tombstoned (soft-deleted) actors too:
|
|
136
|
+
// when an account is deleted, the outbound Delete(actor) deliver_endpoint jobs
|
|
137
|
+
// are snapshotted before teardown but drain afterwards, and the actor row is
|
|
138
|
+
// tombstoned (deletedAt set) rather than hard-deleted precisely so its private
|
|
139
|
+
// key survives for signing here. So this lookup deliberately does NOT filter
|
|
140
|
+
// on `deletedAt`.
|
|
141
|
+
const actorRow = await db
|
|
142
|
+
.select({
|
|
143
|
+
apId: actors.apId,
|
|
144
|
+
privateKeyPem: actors.privateKeyPem,
|
|
145
|
+
})
|
|
146
|
+
.from(actors)
|
|
147
|
+
.where(eq(actors.apId, actorApId))
|
|
148
|
+
.get();
|
|
149
|
+
if (actorRow?.privateKeyPem) {
|
|
150
|
+
return { apId: actorRow.apId, privateKeyPem: actorRow.privateKeyPem };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check communities table
|
|
154
|
+
const communityRow = await db
|
|
155
|
+
.select({
|
|
156
|
+
apId: communities.apId,
|
|
157
|
+
privateKeyPem: communities.privateKeyPem,
|
|
158
|
+
})
|
|
159
|
+
.from(communities)
|
|
160
|
+
.where(eq(communities.apId, actorApId))
|
|
161
|
+
.get();
|
|
162
|
+
if (communityRow?.privateKeyPem) {
|
|
163
|
+
return {
|
|
164
|
+
apId: communityRow.apId,
|
|
165
|
+
privateKeyPem: communityRow.privateKeyPem,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check instanceActor table
|
|
170
|
+
const instanceRow = await db
|
|
171
|
+
.select({
|
|
172
|
+
apId: instanceActor.apId,
|
|
173
|
+
privateKeyPem: instanceActor.privateKeyPem,
|
|
174
|
+
})
|
|
175
|
+
.from(instanceActor)
|
|
176
|
+
.where(eq(instanceActor.apId, actorApId))
|
|
177
|
+
.get();
|
|
178
|
+
if (instanceRow?.privateKeyPem) {
|
|
179
|
+
return { apId: instanceRow.apId, privateKeyPem: instanceRow.privateKeyPem };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function failJob(
|
|
186
|
+
db: Database,
|
|
187
|
+
jobId: string,
|
|
188
|
+
error: string,
|
|
189
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
await db
|
|
192
|
+
.update(deliveryQueue)
|
|
193
|
+
.set({
|
|
194
|
+
status: "failed",
|
|
195
|
+
error,
|
|
196
|
+
lastAttemptAt: nowIso(),
|
|
197
|
+
processingStartedAt: null,
|
|
198
|
+
})
|
|
199
|
+
.where(eq(deliveryQueue.id, jobId));
|
|
200
|
+
message.ack();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function incrementDeliveryAttempts(
|
|
204
|
+
db: Database,
|
|
205
|
+
jobId: string,
|
|
206
|
+
): Promise<number> {
|
|
207
|
+
await db
|
|
208
|
+
.update(deliveryQueue)
|
|
209
|
+
.set({ attempts: sql`${deliveryQueue.attempts} + 1` })
|
|
210
|
+
.where(eq(deliveryQueue.id, jobId));
|
|
211
|
+
const updated = await db
|
|
212
|
+
.select({ attempts: deliveryQueue.attempts })
|
|
213
|
+
.from(deliveryQueue)
|
|
214
|
+
.where(eq(deliveryQueue.id, jobId))
|
|
215
|
+
.get();
|
|
216
|
+
return updated?.attempts ?? 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function maybeShadowProbeInbox(
|
|
220
|
+
db: Database,
|
|
221
|
+
env: Env,
|
|
222
|
+
params: {
|
|
223
|
+
activityId: string;
|
|
224
|
+
sharedInboxEndpoint: string;
|
|
225
|
+
endpointHost: string;
|
|
226
|
+
sender: { apId: string; privateKeyPem: string };
|
|
227
|
+
body: string;
|
|
228
|
+
},
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const allowedHosts = parseCommaSeparated(env.DELIVERY_SHADOW_PROBE_HOSTS);
|
|
231
|
+
if (allowedHosts.length === 0) return;
|
|
232
|
+
if (!allowedHosts.includes(params.endpointHost)) return;
|
|
233
|
+
|
|
234
|
+
const sampleRate = parseSampleRate(env.DELIVERY_SHADOW_PROBE_SAMPLE_RATE);
|
|
235
|
+
if (sampleRate <= 0) return;
|
|
236
|
+
if (sampleRate < 1 && Math.random() > sampleRate) return;
|
|
237
|
+
|
|
238
|
+
const rep = await db
|
|
239
|
+
.select({
|
|
240
|
+
apId: actorCache.apId,
|
|
241
|
+
inbox: actorCache.inbox,
|
|
242
|
+
})
|
|
243
|
+
.from(actorCache)
|
|
244
|
+
.where(eq(actorCache.sharedInbox, params.sharedInboxEndpoint))
|
|
245
|
+
.limit(1)
|
|
246
|
+
.get();
|
|
247
|
+
const inbox = rep?.inbox;
|
|
248
|
+
if (!inbox || !isSafeRemoteUrl(inbox)) return;
|
|
249
|
+
|
|
250
|
+
const inboxHost = safeEndpointHost(inbox);
|
|
251
|
+
const keyId = `${params.sender.apId}#main-key`;
|
|
252
|
+
const headers = await signRequest(
|
|
253
|
+
params.sender.privateKeyPem,
|
|
254
|
+
keyId,
|
|
255
|
+
"POST",
|
|
256
|
+
inbox,
|
|
257
|
+
params.body,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const { response, error, latencyMs } = await timedFetch(inbox, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: {
|
|
263
|
+
...headers,
|
|
264
|
+
"Content-Type": "application/activity+json",
|
|
265
|
+
"X-Yurucommu-Shadow-Probe": "1",
|
|
266
|
+
},
|
|
267
|
+
body: params.body,
|
|
268
|
+
timeout: DELIVERY_HTTP_TIMEOUT_MS,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
emitMetric("delivery_shadow_probe_inbox_latency_ms", latencyMs, {
|
|
272
|
+
endpoint_host: params.endpointHost,
|
|
273
|
+
inbox_host: inboxHost ?? "unknown",
|
|
274
|
+
ok: response?.ok ?? false,
|
|
275
|
+
status: response?.status ?? null,
|
|
276
|
+
});
|
|
277
|
+
emitMetric("delivery_shadow_probe_inbox_ok", response?.ok ? 1 : 0, {
|
|
278
|
+
endpoint_host: params.endpointHost,
|
|
279
|
+
inbox_host: inboxHost ?? "unknown",
|
|
280
|
+
status: response?.status ?? null,
|
|
281
|
+
error: error instanceof Error ? error.message : null,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function processDeliverEndpoint(
|
|
286
|
+
db: Database,
|
|
287
|
+
env: Env,
|
|
288
|
+
msg: DeliveryDeliverEndpointMessageV1,
|
|
289
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
290
|
+
bulkhead: Bulkhead,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
if (!requireQueue(env, "deliver_endpoint", message)) return;
|
|
293
|
+
|
|
294
|
+
const job = await db
|
|
295
|
+
.select({
|
|
296
|
+
id: deliveryQueue.id,
|
|
297
|
+
activityApId: deliveryQueue.activityApId,
|
|
298
|
+
inboxUrl: deliveryQueue.inboxUrl,
|
|
299
|
+
attempts: deliveryQueue.attempts,
|
|
300
|
+
status: deliveryQueue.status,
|
|
301
|
+
nextAttemptAt: deliveryQueue.nextAttemptAt,
|
|
302
|
+
processingStartedAt: deliveryQueue.processingStartedAt,
|
|
303
|
+
})
|
|
304
|
+
.from(deliveryQueue)
|
|
305
|
+
.where(eq(deliveryQueue.id, msg.jobId))
|
|
306
|
+
.get();
|
|
307
|
+
|
|
308
|
+
const TERMINAL_JOB_STATUSES: readonly string[] = [
|
|
309
|
+
"delivered",
|
|
310
|
+
"dead_letter",
|
|
311
|
+
"failed",
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
if (!job || TERMINAL_JOB_STATUSES.includes(job.status)) {
|
|
315
|
+
message.ack();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Queue lag metric
|
|
320
|
+
const scheduledMs = safeParseIsoTimeMs(msg.scheduledAt);
|
|
321
|
+
if (scheduledMs !== null) {
|
|
322
|
+
emitMetric(
|
|
323
|
+
"delivery_queue_lag_seconds",
|
|
324
|
+
Math.max(0, (Date.now() - scheduledMs) / 1000),
|
|
325
|
+
{
|
|
326
|
+
endpoint_host: safeEndpointHost(job.inboxUrl) ?? "unknown",
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Respect job scheduling stored in DB
|
|
332
|
+
const nextAttemptMs = safeParseIsoTimeMs(job.nextAttemptAt);
|
|
333
|
+
if (nextAttemptMs !== null && Date.now() < nextAttemptMs) {
|
|
334
|
+
const deferSeconds = Math.max(
|
|
335
|
+
1,
|
|
336
|
+
Math.ceil((nextAttemptMs - Date.now()) / 1000),
|
|
337
|
+
);
|
|
338
|
+
await sendQueueMessage(
|
|
339
|
+
env,
|
|
340
|
+
// Preserve the reconcile-cycle count across the re-enqueue — dropping it
|
|
341
|
+
// resets the budget to 0 every cycle, so a permanently-dead endpoint
|
|
342
|
+
// would reconcile forever (#6).
|
|
343
|
+
buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
|
|
344
|
+
deferSeconds,
|
|
345
|
+
);
|
|
346
|
+
message.ack();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (job.status === "processing") {
|
|
351
|
+
const startedMs = safeParseIsoTimeMs(job.processingStartedAt);
|
|
352
|
+
if (startedMs !== null && Date.now() - startedMs < STALE_PROCESSING_MS) {
|
|
353
|
+
await sendQueueMessage(
|
|
354
|
+
env,
|
|
355
|
+
buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
|
|
356
|
+
30,
|
|
357
|
+
);
|
|
358
|
+
message.ack();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const endpoint = job.inboxUrl;
|
|
364
|
+
const host = safeEndpointHost(endpoint);
|
|
365
|
+
if (!host) {
|
|
366
|
+
await failJob(db, job.id, "invalid_endpoint", message);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Circuit breaker
|
|
371
|
+
const circuit = await checkCircuit(db, endpoint);
|
|
372
|
+
if (!circuit.allow) {
|
|
373
|
+
emitMetric("delivery_circuit_open_count", 1, { endpoint_host: host });
|
|
374
|
+
await sendQueueMessage(
|
|
375
|
+
env,
|
|
376
|
+
buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
|
|
377
|
+
circuit.deferSeconds,
|
|
378
|
+
);
|
|
379
|
+
message.ack();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
await bulkhead.acquire(host);
|
|
384
|
+
try {
|
|
385
|
+
// Mark processing — VERIFIED CAS. Cloudflare Queues is at-least-once and the
|
|
386
|
+
// SELECT above ran BEFORE bulkhead.acquire (the bulkhead is keyed by host,
|
|
387
|
+
// not jobId), so two runners can hold the same jobId concurrently and both
|
|
388
|
+
// reach here. Claim only if the row is still claimable: a non-processing,
|
|
389
|
+
// non-terminal status, OR a STALE 'processing' row (a crashed runner — the
|
|
390
|
+
// same condition the pre-acquire stale check at :316 lets fall through). If
|
|
391
|
+
// our UPDATE changes 0 rows another live runner already owns the job → ack
|
|
392
|
+
// without re-enqueue so the signed activity is POSTed exactly once.
|
|
393
|
+
const staleThresholdIso = new Date(
|
|
394
|
+
Date.now() - STALE_PROCESSING_MS,
|
|
395
|
+
).toISOString();
|
|
396
|
+
const claim = await db
|
|
397
|
+
.update(deliveryQueue)
|
|
398
|
+
.set({
|
|
399
|
+
status: "processing",
|
|
400
|
+
processingStartedAt: nowIso(),
|
|
401
|
+
})
|
|
402
|
+
.where(
|
|
403
|
+
and(
|
|
404
|
+
eq(deliveryQueue.id, job.id),
|
|
405
|
+
or(
|
|
406
|
+
notInArray(deliveryQueue.status, [
|
|
407
|
+
"processing",
|
|
408
|
+
...TERMINAL_JOB_STATUSES,
|
|
409
|
+
]),
|
|
410
|
+
and(
|
|
411
|
+
eq(deliveryQueue.status, "processing"),
|
|
412
|
+
lt(deliveryQueue.processingStartedAt, staleThresholdIso),
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
if (((claim as { meta?: { changes?: number } }).meta?.changes ?? 0) === 0) {
|
|
418
|
+
message.ack();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const activity = await db
|
|
423
|
+
.select({
|
|
424
|
+
rawJson: activities.rawJson,
|
|
425
|
+
actorApId: activities.actorApId,
|
|
426
|
+
})
|
|
427
|
+
.from(activities)
|
|
428
|
+
.where(eq(activities.apId, job.activityApId))
|
|
429
|
+
.get();
|
|
430
|
+
if (!activity) {
|
|
431
|
+
await failJob(db, job.id, "activity_not_found", message);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sender = await resolveSigningActor(db, activity.actorApId);
|
|
436
|
+
if (!sender) {
|
|
437
|
+
await failJob(db, job.id, "signing_actor_not_found", message);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const body = activity.rawJson;
|
|
442
|
+
const keyId = `${sender.apId}#main-key`;
|
|
443
|
+
const headers = await signRequest(
|
|
444
|
+
sender.privateKeyPem,
|
|
445
|
+
keyId,
|
|
446
|
+
"POST",
|
|
447
|
+
endpoint,
|
|
448
|
+
body,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const { response, error, latencyMs } = await timedFetch(endpoint, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: { ...headers, "Content-Type": "application/activity+json" },
|
|
454
|
+
body,
|
|
455
|
+
timeout: DELIVERY_HTTP_TIMEOUT_MS,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
emitMetric("delivery_latency_ms", latencyMs, {
|
|
459
|
+
endpoint_host: host,
|
|
460
|
+
ok: response?.ok ?? false,
|
|
461
|
+
status: response?.status ?? null,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (response?.ok) {
|
|
465
|
+
// The remote POST already succeeded. From here on the message MUST be
|
|
466
|
+
// acked even if the bookkeeping DB writes fail: re-running the batch
|
|
467
|
+
// would re-POST the same activity and produce a duplicate delivery. A
|
|
468
|
+
// stale 'processing' row left behind by a failed update is harmless
|
|
469
|
+
// (it gets reaped/retried only via reconcile, which re-checks state).
|
|
470
|
+
const now = nowIso();
|
|
471
|
+
try {
|
|
472
|
+
await db
|
|
473
|
+
.update(deliveryQueue)
|
|
474
|
+
.set({
|
|
475
|
+
status: "delivered",
|
|
476
|
+
deliveredAt: now,
|
|
477
|
+
error: null,
|
|
478
|
+
lastAttemptAt: now,
|
|
479
|
+
processingStartedAt: null,
|
|
480
|
+
})
|
|
481
|
+
.where(eq(deliveryQueue.id, job.id));
|
|
482
|
+
await recordCircuitSuccess(db, endpoint);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
log.error("Post-delivery bookkeeping failed; acking to avoid re-POST", {
|
|
485
|
+
event: "delivery.deliver.post_success_update_failed",
|
|
486
|
+
jobId: job.id,
|
|
487
|
+
activityId: job.activityApId,
|
|
488
|
+
endpoint,
|
|
489
|
+
endpointHost: host,
|
|
490
|
+
error: e,
|
|
491
|
+
});
|
|
492
|
+
emitMetric("delivery_success", 1, {
|
|
493
|
+
endpoint_host: host,
|
|
494
|
+
bookkeeping_failed: true,
|
|
495
|
+
});
|
|
496
|
+
message.ack();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
emitMetric("delivery_success", 1, { endpoint_host: host });
|
|
500
|
+
|
|
501
|
+
// Shadow probe (staging-only)
|
|
502
|
+
if (job.attempts === 0) {
|
|
503
|
+
try {
|
|
504
|
+
await maybeShadowProbeInbox(db, env, {
|
|
505
|
+
activityId: job.activityApId,
|
|
506
|
+
sharedInboxEndpoint: endpoint,
|
|
507
|
+
endpointHost: host,
|
|
508
|
+
sender,
|
|
509
|
+
body,
|
|
510
|
+
});
|
|
511
|
+
} catch (e) {
|
|
512
|
+
log.warn("Shadow probe failed", {
|
|
513
|
+
event: "delivery.shadow_probe.failed",
|
|
514
|
+
activityId: job.activityApId,
|
|
515
|
+
endpoint,
|
|
516
|
+
endpointHost: host,
|
|
517
|
+
error: e,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
message.ack();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const status = response?.status ?? null;
|
|
527
|
+
const errorMessage = buildErrorMessage(response, error);
|
|
528
|
+
|
|
529
|
+
// 404/410: expire endpoint cache immediately
|
|
530
|
+
if (status === 404 || status === 410) {
|
|
531
|
+
try {
|
|
532
|
+
await enqueueResolveForEndpointActors(
|
|
533
|
+
db,
|
|
534
|
+
env,
|
|
535
|
+
job.activityApId,
|
|
536
|
+
endpoint,
|
|
537
|
+
);
|
|
538
|
+
await db
|
|
539
|
+
.update(actorCache)
|
|
540
|
+
.set({ sharedInbox: null, lastFetchedAt: EPOCH_ISO })
|
|
541
|
+
.where(eq(actorCache.sharedInbox, endpoint));
|
|
542
|
+
await db
|
|
543
|
+
.update(actorCache)
|
|
544
|
+
.set({ lastFetchedAt: EPOCH_ISO })
|
|
545
|
+
.where(eq(actorCache.inbox, endpoint));
|
|
546
|
+
} catch (e) {
|
|
547
|
+
log.warn("Endpoint invalidation failed", {
|
|
548
|
+
event: "delivery.endpoint.invalidation_failed",
|
|
549
|
+
endpoint,
|
|
550
|
+
endpointHost: host,
|
|
551
|
+
status,
|
|
552
|
+
error: e,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Permanent 4xx => fail; transient 4xx (TRANSIENT_DELIVERY_4XX) and 5xx are
|
|
558
|
+
// retried. See isPermanentDeliveryFailure for the classification rationale.
|
|
559
|
+
const nonRetryable = isPermanentDeliveryFailure(status);
|
|
560
|
+
if (nonRetryable) {
|
|
561
|
+
await failJob(db, job.id, errorMessage, message);
|
|
562
|
+
emitMetric("delivery_success", 0, {
|
|
563
|
+
endpoint_host: host,
|
|
564
|
+
non_retryable: true,
|
|
565
|
+
status,
|
|
566
|
+
});
|
|
567
|
+
// Do NOT trip the circuit on a permanent 4xx: a 400/403/410/422 is the
|
|
568
|
+
// remote's per-ACTIVITY verdict (malformed/forbidden/gone for THIS post),
|
|
569
|
+
// not a signal the endpoint is unhealthy. The circuit is keyed by the
|
|
570
|
+
// shared inbox, so counting per-activity rejections would let one bad
|
|
571
|
+
// activity (or one over-strict remote) open the breaker and block delivery
|
|
572
|
+
// of unrelated, valid activities to every co-located recipient. Only
|
|
573
|
+
// transient failures (below) reflect endpoint health.
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Retryable failure — this DOES reflect endpoint health, so feed the circuit.
|
|
578
|
+
const nextAttempts = await incrementDeliveryAttempts(db, job.id);
|
|
579
|
+
await recordCircuitFailure(db, endpoint);
|
|
580
|
+
|
|
581
|
+
if (nextAttempts >= DELIVERY_MAX_ATTEMPTS) {
|
|
582
|
+
const now = nowIso();
|
|
583
|
+
await db
|
|
584
|
+
.update(deliveryQueue)
|
|
585
|
+
.set({
|
|
586
|
+
status: "dead_letter",
|
|
587
|
+
error: errorMessage,
|
|
588
|
+
lastAttemptAt: now,
|
|
589
|
+
processingStartedAt: null,
|
|
590
|
+
})
|
|
591
|
+
.where(eq(deliveryQueue.id, job.id));
|
|
592
|
+
emitMetric("delivery_dead_letter", 1, { endpoint_host: host });
|
|
593
|
+
|
|
594
|
+
await sendDlqMessage(env, {
|
|
595
|
+
version: 1,
|
|
596
|
+
type: "dlq",
|
|
597
|
+
jobId: job.id,
|
|
598
|
+
activityId: job.activityApId,
|
|
599
|
+
endpoint,
|
|
600
|
+
attempts: nextAttempts,
|
|
601
|
+
lastError: errorMessage,
|
|
602
|
+
// Carry the job's reconcile-cycle count so the DLQ consumer can advance
|
|
603
|
+
// (and ultimately terminate) the reconciliation budget.
|
|
604
|
+
reconcileAttempt: msg.reconcileAttempt ?? 0,
|
|
605
|
+
deadLetteredAt: now,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
message.ack();
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const delaySeconds = computeRetryDelaySeconds(nextAttempts);
|
|
613
|
+
const nextAttemptAtStr = new Date(
|
|
614
|
+
Date.now() + delaySeconds * 1000,
|
|
615
|
+
).toISOString();
|
|
616
|
+
|
|
617
|
+
await db
|
|
618
|
+
.update(deliveryQueue)
|
|
619
|
+
.set({
|
|
620
|
+
status: "retry_wait",
|
|
621
|
+
error: errorMessage,
|
|
622
|
+
lastAttemptAt: nowIso(),
|
|
623
|
+
processingStartedAt: null,
|
|
624
|
+
nextAttemptAt: nextAttemptAtStr,
|
|
625
|
+
})
|
|
626
|
+
.where(eq(deliveryQueue.id, job.id));
|
|
627
|
+
|
|
628
|
+
emitMetric("delivery_success", 0, {
|
|
629
|
+
endpoint_host: host,
|
|
630
|
+
status: status ?? null,
|
|
631
|
+
});
|
|
632
|
+
await sendQueueMessage(
|
|
633
|
+
env,
|
|
634
|
+
buildDeliverEndpointMessage(job.id, msg.reconcileAttempt ?? 0),
|
|
635
|
+
delaySeconds,
|
|
636
|
+
);
|
|
637
|
+
message.ack();
|
|
638
|
+
} finally {
|
|
639
|
+
bulkhead.release(host);
|
|
640
|
+
}
|
|
641
|
+
}
|