@takosjp/yurucommu-core 3.4.3 → 3.4.4
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/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -14,8 +14,27 @@
|
|
|
14
14
|
* one fetch/guard/upsert flow, so every cached actor row is now populated
|
|
15
15
|
* identically regardless of entry path.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
18
|
-
|
|
17
|
+
import {
|
|
18
|
+
and,
|
|
19
|
+
asc,
|
|
20
|
+
eq,
|
|
21
|
+
inArray,
|
|
22
|
+
isNull,
|
|
23
|
+
lt,
|
|
24
|
+
lte,
|
|
25
|
+
ne,
|
|
26
|
+
notExists,
|
|
27
|
+
or,
|
|
28
|
+
sql,
|
|
29
|
+
} from "drizzle-orm";
|
|
30
|
+
import {
|
|
31
|
+
actorCache,
|
|
32
|
+
affectedRowCount,
|
|
33
|
+
type D1Statement,
|
|
34
|
+
remoteActorFetchFailures,
|
|
35
|
+
remoteActorTombstones,
|
|
36
|
+
runBatch,
|
|
37
|
+
} from "../../db/index.ts";
|
|
19
38
|
import type { Database } from "../../db/index.ts";
|
|
20
39
|
import {
|
|
21
40
|
fetchWithTimeout,
|
|
@@ -26,6 +45,7 @@ import {
|
|
|
26
45
|
tryParseRemoteActor,
|
|
27
46
|
type RemoteActorDocument,
|
|
28
47
|
} from "./activitypub-validators.ts";
|
|
48
|
+
import { normalizeActivityPubActorId } from "./activitypub-actor-identity.ts";
|
|
29
49
|
|
|
30
50
|
/**
|
|
31
51
|
* The signing identity used to HTTP-sign an outbound actor GET so instances
|
|
@@ -115,11 +135,459 @@ export type ActorCacheFailureReason =
|
|
|
115
135
|
| "invalid_document" // body did not parse as a remote actor
|
|
116
136
|
| "id_mismatch" // returned `id` did not match the requested URL
|
|
117
137
|
| "missing_inbox" // no inbox, or inbox/id failed the SSRF safety check
|
|
118
|
-
| "missing_public_key"
|
|
138
|
+
| "missing_public_key" // required public key absent (mode === "require-key")
|
|
139
|
+
| "actor_tombstoned"; // verified Delete(Actor) is durable authority
|
|
119
140
|
|
|
120
141
|
export type ActorCacheResult =
|
|
121
142
|
| { ok: true; data: RemoteActorDocument; row: typeof actorCache.$inferSelect }
|
|
122
|
-
| {
|
|
143
|
+
| {
|
|
144
|
+
ok: false;
|
|
145
|
+
reason: "fetch_not_ok";
|
|
146
|
+
status: number;
|
|
147
|
+
retryAfterSeconds: number | null;
|
|
148
|
+
}
|
|
149
|
+
| {
|
|
150
|
+
ok: false;
|
|
151
|
+
reason: Exclude<ActorCacheFailureReason, "fetch_not_ok">;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type ActorCacheFailureResult = Extract<ActorCacheResult, { ok: false }>;
|
|
155
|
+
|
|
156
|
+
function canonicalRemoteActorTombstoneId(actorApId: string): string {
|
|
157
|
+
return normalizeActivityPubActorId(actorApId) ?? actorApId;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Return the durable self-Delete authority for one actor identity, if any. */
|
|
161
|
+
export async function getRemoteActorTombstone(
|
|
162
|
+
db: Database,
|
|
163
|
+
actorApId: string,
|
|
164
|
+
): Promise<typeof remoteActorTombstones.$inferSelect | null> {
|
|
165
|
+
return (
|
|
166
|
+
(await db
|
|
167
|
+
.select()
|
|
168
|
+
.from(remoteActorTombstones)
|
|
169
|
+
.where(
|
|
170
|
+
eq(
|
|
171
|
+
remoteActorTombstones.actorApId,
|
|
172
|
+
canonicalRemoteActorTombstoneId(actorApId),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
.get()) ?? null
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function isRemoteActorTombstoned(
|
|
180
|
+
db: Database,
|
|
181
|
+
actorApId: string,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
return (await getRemoteActorTombstone(db, actorApId)) !== null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type RemoteActorFetchFailureKind = "gone" | "unavailable" | "invalid";
|
|
187
|
+
|
|
188
|
+
export interface RemoteActorFetchFailureState {
|
|
189
|
+
readonly actorApId: string;
|
|
190
|
+
readonly kind: RemoteActorFetchFailureKind;
|
|
191
|
+
readonly reason: ActorCacheFailureReason;
|
|
192
|
+
readonly httpStatus: number | null;
|
|
193
|
+
readonly failureCount: number;
|
|
194
|
+
readonly retryAt: string | null;
|
|
195
|
+
readonly retryAfterSeconds: number | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const TRANSIENT_BACKOFF_BASE_SECONDS = 30;
|
|
199
|
+
const INVALID_BACKOFF_BASE_SECONDS = 5 * 60;
|
|
200
|
+
const MAX_FETCH_BACKOFF_SECONDS = 60 * 60;
|
|
201
|
+
const MAX_REMOTE_RETRY_AFTER_SECONDS = 24 * 60 * 60;
|
|
202
|
+
const FETCH_FAILURE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
203
|
+
const FETCH_FAILURE_REAP_LIMIT = 50;
|
|
204
|
+
// The default remote GET timeout is 15s. Keep the claim alive for twice that
|
|
205
|
+
// window so request scheduling / D1 latency cannot let a peer steal it while
|
|
206
|
+
// the owner is still completing the bounded network call.
|
|
207
|
+
const REMOTE_ACTOR_FETCH_LEASE_MS = 30 * 1000;
|
|
208
|
+
|
|
209
|
+
function clampSeconds(seconds: number, max: number): number {
|
|
210
|
+
return Math.max(1, Math.min(max, Math.ceil(seconds)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseRetryAfterSeconds(
|
|
214
|
+
value: string | null,
|
|
215
|
+
nowMs: number,
|
|
216
|
+
): number | null {
|
|
217
|
+
if (!value) return null;
|
|
218
|
+
const numeric = Number(value);
|
|
219
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
220
|
+
return clampSeconds(numeric, MAX_REMOTE_RETRY_AFTER_SECONDS);
|
|
221
|
+
}
|
|
222
|
+
const dateMs = Date.parse(value);
|
|
223
|
+
if (!Number.isFinite(dateMs) || dateMs <= nowMs) return null;
|
|
224
|
+
return clampSeconds((dateMs - nowMs) / 1000, MAX_REMOTE_RETRY_AFTER_SECONDS);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function classifyFetchFailure(
|
|
228
|
+
failure: ActorCacheFailureResult,
|
|
229
|
+
): RemoteActorFetchFailureKind {
|
|
230
|
+
if (failure.reason === "fetch_not_ok" && failure.status === 410) {
|
|
231
|
+
return "gone";
|
|
232
|
+
}
|
|
233
|
+
if (failure.reason === "actor_tombstoned") return "gone";
|
|
234
|
+
if (
|
|
235
|
+
failure.reason === "invalid_document" ||
|
|
236
|
+
failure.reason === "id_mismatch" ||
|
|
237
|
+
failure.reason === "missing_inbox" ||
|
|
238
|
+
failure.reason === "missing_public_key"
|
|
239
|
+
) {
|
|
240
|
+
return "invalid";
|
|
241
|
+
}
|
|
242
|
+
return "unavailable";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function failureBackoffSeconds(
|
|
246
|
+
kind: RemoteActorFetchFailureKind,
|
|
247
|
+
failureCount: number,
|
|
248
|
+
remoteRetryAfterSeconds: number | null,
|
|
249
|
+
): number | null {
|
|
250
|
+
if (kind === "gone") return null;
|
|
251
|
+
const base =
|
|
252
|
+
kind === "invalid"
|
|
253
|
+
? INVALID_BACKOFF_BASE_SECONDS
|
|
254
|
+
: TRANSIENT_BACKOFF_BASE_SECONDS;
|
|
255
|
+
const exponent = Math.min(Math.max(0, failureCount - 1), 8);
|
|
256
|
+
const exponential = Math.min(MAX_FETCH_BACKOFF_SECONDS, base * 2 ** exponent);
|
|
257
|
+
return Math.max(exponential, remoteRetryAfterSeconds ?? 0);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function stateFromRow(
|
|
261
|
+
row: typeof remoteActorFetchFailures.$inferSelect,
|
|
262
|
+
nowMs: number,
|
|
263
|
+
): RemoteActorFetchFailureState {
|
|
264
|
+
const retryAtMs = row.retryAt ? Date.parse(row.retryAt) : Number.NaN;
|
|
265
|
+
const retryAfterSeconds =
|
|
266
|
+
row.kind === "gone" || !Number.isFinite(retryAtMs)
|
|
267
|
+
? null
|
|
268
|
+
: clampSeconds(
|
|
269
|
+
(retryAtMs - nowMs) / 1000,
|
|
270
|
+
MAX_REMOTE_RETRY_AFTER_SECONDS,
|
|
271
|
+
);
|
|
272
|
+
return {
|
|
273
|
+
actorApId: row.actorApId,
|
|
274
|
+
kind: row.kind as RemoteActorFetchFailureKind,
|
|
275
|
+
reason: row.reason as ActorCacheFailureReason,
|
|
276
|
+
httpStatus: row.httpStatus,
|
|
277
|
+
failureCount: row.failureCount,
|
|
278
|
+
retryAt: row.retryAt,
|
|
279
|
+
retryAfterSeconds,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Return an active terminal/cooldown decision. An expired retry window returns
|
|
285
|
+
* null so the caller may make one new network attempt and update the ledger.
|
|
286
|
+
*/
|
|
287
|
+
export async function getRemoteActorFetchFailure(
|
|
288
|
+
db: Database,
|
|
289
|
+
actorApId: string,
|
|
290
|
+
now: Date = new Date(),
|
|
291
|
+
): Promise<RemoteActorFetchFailureState | null> {
|
|
292
|
+
const row = await db
|
|
293
|
+
.select()
|
|
294
|
+
.from(remoteActorFetchFailures)
|
|
295
|
+
.where(eq(remoteActorFetchFailures.actorApId, actorApId))
|
|
296
|
+
.get();
|
|
297
|
+
if (!row) return null;
|
|
298
|
+
if (row.kind === "gone") return stateFromRow(row, now.getTime());
|
|
299
|
+
const retryAtMs = row.retryAt ? Date.parse(row.retryAt) : Number.NaN;
|
|
300
|
+
if (!Number.isFinite(retryAtMs) || retryAtMs <= now.getTime()) return null;
|
|
301
|
+
return stateFromRow(row, now.getTime());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type RemoteActorFetchClaim =
|
|
305
|
+
| { readonly owned: true; readonly token: string }
|
|
306
|
+
| {
|
|
307
|
+
readonly owned: false;
|
|
308
|
+
readonly failure: RemoteActorFetchFailureState;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
function activeLeaseFailure(
|
|
312
|
+
row: typeof remoteActorFetchFailures.$inferSelect,
|
|
313
|
+
nowMs: number,
|
|
314
|
+
): RemoteActorFetchFailureState {
|
|
315
|
+
const leaseExpiresAtMs = row.leaseExpiresAt
|
|
316
|
+
? Date.parse(row.leaseExpiresAt)
|
|
317
|
+
: Number.NaN;
|
|
318
|
+
const retryAfterSeconds = Number.isFinite(leaseExpiresAtMs)
|
|
319
|
+
? clampSeconds(
|
|
320
|
+
(leaseExpiresAtMs - nowMs) / 1000,
|
|
321
|
+
MAX_REMOTE_RETRY_AFTER_SECONDS,
|
|
322
|
+
)
|
|
323
|
+
: TRANSIENT_BACKOFF_BASE_SECONDS;
|
|
324
|
+
return {
|
|
325
|
+
actorApId: row.actorApId,
|
|
326
|
+
kind: "unavailable",
|
|
327
|
+
reason: "fetch_failed",
|
|
328
|
+
httpStatus: null,
|
|
329
|
+
failureCount: row.failureCount,
|
|
330
|
+
retryAt: Number.isFinite(leaseExpiresAtMs) ? row.leaseExpiresAt : null,
|
|
331
|
+
retryAfterSeconds,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function currentFailureOrLease(
|
|
336
|
+
row: typeof remoteActorFetchFailures.$inferSelect,
|
|
337
|
+
nowMs: number,
|
|
338
|
+
): RemoteActorFetchFailureState {
|
|
339
|
+
if (row.kind === "gone") return stateFromRow(row, nowMs);
|
|
340
|
+
const retryAtMs = row.retryAt ? Date.parse(row.retryAt) : Number.NaN;
|
|
341
|
+
if (Number.isFinite(retryAtMs) && retryAtMs > nowMs) {
|
|
342
|
+
return stateFromRow(row, nowMs);
|
|
343
|
+
}
|
|
344
|
+
return activeLeaseFailure(row, nowMs);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Claim the one remote GET allowed for an actor whose cache row is missing.
|
|
349
|
+
*
|
|
350
|
+
* The token fences completion: if a slow owner outlives this bounded lease, it
|
|
351
|
+
* cannot overwrite the failure recorded by a newer owner or clear a cache
|
|
352
|
+
* decision after that newer fetch succeeds.
|
|
353
|
+
*/
|
|
354
|
+
export async function claimRemoteActorFetch(
|
|
355
|
+
db: Database,
|
|
356
|
+
actorApId: string,
|
|
357
|
+
now: Date = new Date(),
|
|
358
|
+
): Promise<RemoteActorFetchClaim> {
|
|
359
|
+
const nowIso = now.toISOString();
|
|
360
|
+
const token = crypto.randomUUID();
|
|
361
|
+
const leaseExpiresAt = new Date(
|
|
362
|
+
now.getTime() + REMOTE_ACTOR_FETCH_LEASE_MS,
|
|
363
|
+
).toISOString();
|
|
364
|
+
|
|
365
|
+
const inserted = await db
|
|
366
|
+
.insert(remoteActorFetchFailures)
|
|
367
|
+
.values({
|
|
368
|
+
actorApId,
|
|
369
|
+
kind: "unavailable",
|
|
370
|
+
reason: "fetch_failed",
|
|
371
|
+
httpStatus: null,
|
|
372
|
+
failureCount: 0,
|
|
373
|
+
retryAt: null,
|
|
374
|
+
processingToken: token,
|
|
375
|
+
leaseExpiresAt,
|
|
376
|
+
createdAt: nowIso,
|
|
377
|
+
updatedAt: nowIso,
|
|
378
|
+
})
|
|
379
|
+
.onConflictDoNothing();
|
|
380
|
+
if (affectedRowCount(inserted) > 0) return { owned: true, token };
|
|
381
|
+
|
|
382
|
+
const claimed = await db
|
|
383
|
+
.update(remoteActorFetchFailures)
|
|
384
|
+
.set({ processingToken: token, leaseExpiresAt, updatedAt: nowIso })
|
|
385
|
+
.where(
|
|
386
|
+
and(
|
|
387
|
+
eq(remoteActorFetchFailures.actorApId, actorApId),
|
|
388
|
+
ne(remoteActorFetchFailures.kind, "gone"),
|
|
389
|
+
or(
|
|
390
|
+
isNull(remoteActorFetchFailures.retryAt),
|
|
391
|
+
lte(remoteActorFetchFailures.retryAt, nowIso),
|
|
392
|
+
),
|
|
393
|
+
or(
|
|
394
|
+
isNull(remoteActorFetchFailures.processingToken),
|
|
395
|
+
isNull(remoteActorFetchFailures.leaseExpiresAt),
|
|
396
|
+
lte(remoteActorFetchFailures.leaseExpiresAt, nowIso),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
if (affectedRowCount(claimed) > 0) return { owned: true, token };
|
|
401
|
+
|
|
402
|
+
const current = await db
|
|
403
|
+
.select()
|
|
404
|
+
.from(remoteActorFetchFailures)
|
|
405
|
+
.where(eq(remoteActorFetchFailures.actorApId, actorApId))
|
|
406
|
+
.get();
|
|
407
|
+
if (!current) {
|
|
408
|
+
// A successful owner may have deleted the ledger between our failed claim
|
|
409
|
+
// and this read. The current request did not own a GET, so fail closed and
|
|
410
|
+
// let a later profile load observe the newly cached actor or retry.
|
|
411
|
+
return {
|
|
412
|
+
owned: false,
|
|
413
|
+
failure: {
|
|
414
|
+
actorApId,
|
|
415
|
+
kind: "unavailable",
|
|
416
|
+
reason: "fetch_failed",
|
|
417
|
+
httpStatus: null,
|
|
418
|
+
failureCount: 0,
|
|
419
|
+
retryAt: null,
|
|
420
|
+
retryAfterSeconds: TRANSIENT_BACKOFF_BASE_SECONDS,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
owned: false,
|
|
426
|
+
failure: currentFailureOrLease(current, now.getTime()),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Persist one failed fetch and return the exact response/backoff authority. */
|
|
431
|
+
export async function recordRemoteActorFetchFailure(
|
|
432
|
+
db: Database,
|
|
433
|
+
actorApId: string,
|
|
434
|
+
failure: ActorCacheFailureResult,
|
|
435
|
+
now: Date = new Date(),
|
|
436
|
+
claimToken?: string,
|
|
437
|
+
): Promise<RemoteActorFetchFailureState> {
|
|
438
|
+
const prior = await db
|
|
439
|
+
.select({ failureCount: remoteActorFetchFailures.failureCount })
|
|
440
|
+
.from(remoteActorFetchFailures)
|
|
441
|
+
.where(eq(remoteActorFetchFailures.actorApId, actorApId))
|
|
442
|
+
.get();
|
|
443
|
+
const failureCount = Math.min((prior?.failureCount ?? 0) + 1, 1_000_000);
|
|
444
|
+
const kind = classifyFetchFailure(failure);
|
|
445
|
+
const remoteRetryAfterSeconds =
|
|
446
|
+
failure.reason === "fetch_not_ok" ? failure.retryAfterSeconds : null;
|
|
447
|
+
const backoffSeconds = failureBackoffSeconds(
|
|
448
|
+
kind,
|
|
449
|
+
failureCount,
|
|
450
|
+
remoteRetryAfterSeconds,
|
|
451
|
+
);
|
|
452
|
+
const nowIso = now.toISOString();
|
|
453
|
+
const retryAt =
|
|
454
|
+
backoffSeconds === null
|
|
455
|
+
? null
|
|
456
|
+
: new Date(now.getTime() + backoffSeconds * 1000).toISOString();
|
|
457
|
+
const values = {
|
|
458
|
+
actorApId,
|
|
459
|
+
kind,
|
|
460
|
+
reason: failure.reason,
|
|
461
|
+
httpStatus: failure.reason === "fetch_not_ok" ? failure.status : null,
|
|
462
|
+
failureCount,
|
|
463
|
+
retryAt,
|
|
464
|
+
processingToken: null,
|
|
465
|
+
leaseExpiresAt: null,
|
|
466
|
+
updatedAt: nowIso,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
if (claimToken) {
|
|
470
|
+
const recorded = await db
|
|
471
|
+
.update(remoteActorFetchFailures)
|
|
472
|
+
.set(values)
|
|
473
|
+
.where(
|
|
474
|
+
and(
|
|
475
|
+
eq(remoteActorFetchFailures.actorApId, actorApId),
|
|
476
|
+
eq(remoteActorFetchFailures.processingToken, claimToken),
|
|
477
|
+
notExists(
|
|
478
|
+
db
|
|
479
|
+
.select({ apId: actorCache.apId })
|
|
480
|
+
.from(actorCache)
|
|
481
|
+
.where(eq(actorCache.apId, actorApId)),
|
|
482
|
+
),
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
if (affectedRowCount(recorded) === 0) {
|
|
486
|
+
const cached = await db
|
|
487
|
+
.select({ apId: actorCache.apId })
|
|
488
|
+
.from(actorCache)
|
|
489
|
+
.where(eq(actorCache.apId, actorApId))
|
|
490
|
+
.get();
|
|
491
|
+
if (cached) {
|
|
492
|
+
// A successful writer won the race. Remove only this claimant's stale
|
|
493
|
+
// lease (never a newer owner's) so cache eviction cannot resurrect the
|
|
494
|
+
// losing request's failure decision later.
|
|
495
|
+
await db
|
|
496
|
+
.delete(remoteActorFetchFailures)
|
|
497
|
+
.where(
|
|
498
|
+
and(
|
|
499
|
+
eq(remoteActorFetchFailures.actorApId, actorApId),
|
|
500
|
+
eq(remoteActorFetchFailures.processingToken, claimToken),
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const current = await db
|
|
505
|
+
.select()
|
|
506
|
+
.from(remoteActorFetchFailures)
|
|
507
|
+
.where(eq(remoteActorFetchFailures.actorApId, actorApId))
|
|
508
|
+
.get();
|
|
509
|
+
if (current) return currentFailureOrLease(current, now.getTime());
|
|
510
|
+
return {
|
|
511
|
+
actorApId,
|
|
512
|
+
kind: "unavailable",
|
|
513
|
+
reason: "fetch_failed",
|
|
514
|
+
httpStatus: null,
|
|
515
|
+
failureCount: 0,
|
|
516
|
+
retryAt: null,
|
|
517
|
+
retryAfterSeconds: TRANSIENT_BACKOFF_BASE_SECONDS,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
await db
|
|
522
|
+
.insert(remoteActorFetchFailures)
|
|
523
|
+
.values({ ...values, createdAt: nowIso })
|
|
524
|
+
.onConflictDoUpdate({
|
|
525
|
+
target: remoteActorFetchFailures.actorApId,
|
|
526
|
+
set: values,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
...values,
|
|
532
|
+
retryAfterSeconds: backoffSeconds,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export async function clearRemoteActorFetchFailure(
|
|
537
|
+
db: Database,
|
|
538
|
+
actorApId: string,
|
|
539
|
+
claimToken?: string,
|
|
540
|
+
): Promise<void> {
|
|
541
|
+
await db
|
|
542
|
+
.delete(remoteActorFetchFailures)
|
|
543
|
+
.where(
|
|
544
|
+
claimToken
|
|
545
|
+
? and(
|
|
546
|
+
eq(remoteActorFetchFailures.actorApId, actorApId),
|
|
547
|
+
eq(remoteActorFetchFailures.processingToken, claimToken),
|
|
548
|
+
)
|
|
549
|
+
: eq(remoteActorFetchFailures.actorApId, actorApId),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Remove one D1-safe batch of stale negative-cache/backoff rows. */
|
|
554
|
+
export async function reapRemoteActorFetchFailures(
|
|
555
|
+
db: Database,
|
|
556
|
+
now: Date = new Date(),
|
|
557
|
+
): Promise<number> {
|
|
558
|
+
const cutoff = new Date(
|
|
559
|
+
now.getTime() - FETCH_FAILURE_RETENTION_MS,
|
|
560
|
+
).toISOString();
|
|
561
|
+
const candidates = await db
|
|
562
|
+
.select({ actorApId: remoteActorFetchFailures.actorApId })
|
|
563
|
+
.from(remoteActorFetchFailures)
|
|
564
|
+
.where(
|
|
565
|
+
and(
|
|
566
|
+
lt(remoteActorFetchFailures.updatedAt, cutoff),
|
|
567
|
+
or(
|
|
568
|
+
isNull(remoteActorFetchFailures.leaseExpiresAt),
|
|
569
|
+
lte(remoteActorFetchFailures.leaseExpiresAt, now.toISOString()),
|
|
570
|
+
),
|
|
571
|
+
),
|
|
572
|
+
)
|
|
573
|
+
.orderBy(asc(remoteActorFetchFailures.updatedAt))
|
|
574
|
+
.limit(FETCH_FAILURE_REAP_LIMIT);
|
|
575
|
+
if (candidates.length === 0) return 0;
|
|
576
|
+
const result = await db.delete(remoteActorFetchFailures).where(
|
|
577
|
+
and(
|
|
578
|
+
inArray(
|
|
579
|
+
remoteActorFetchFailures.actorApId,
|
|
580
|
+
candidates.map((row) => row.actorApId),
|
|
581
|
+
),
|
|
582
|
+
lt(remoteActorFetchFailures.updatedAt, cutoff),
|
|
583
|
+
or(
|
|
584
|
+
isNull(remoteActorFetchFailures.leaseExpiresAt),
|
|
585
|
+
lte(remoteActorFetchFailures.leaseExpiresAt, now.toISOString()),
|
|
586
|
+
),
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
return affectedRowCount(result);
|
|
590
|
+
}
|
|
123
591
|
|
|
124
592
|
export interface FetchAndUpsertActorCacheOptions {
|
|
125
593
|
/** Fetch timeout in ms. Defaults to 15s. */
|
|
@@ -145,6 +613,132 @@ export interface FetchAndUpsertActorCacheOptions {
|
|
|
145
613
|
signer?: RemoteFetchSigner;
|
|
146
614
|
}
|
|
147
615
|
|
|
616
|
+
export type CacheRemoteActorDocumentOptions = Pick<
|
|
617
|
+
FetchAndUpsertActorCacheOptions,
|
|
618
|
+
"mode" | "publicKey"
|
|
619
|
+
>;
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Validate and persist an already-fetched remote actor document.
|
|
623
|
+
*
|
|
624
|
+
* This is the single successful-write authority for `actor_cache`: the cache
|
|
625
|
+
* mutation and deletion of any older terminal/cooldown/lease row are committed
|
|
626
|
+
* atomically. Indexed fields come from the bounded parsed shape while
|
|
627
|
+
* `rawJson` preserves the original document, including extension fields such
|
|
628
|
+
* as `attachment`, `alsoKnownAs`, and `endpoints.rtcSignal`.
|
|
629
|
+
*/
|
|
630
|
+
export async function cacheRemoteActorDocument(
|
|
631
|
+
db: Database,
|
|
632
|
+
expectedActorApId: string,
|
|
633
|
+
rawDocument: unknown,
|
|
634
|
+
options: CacheRemoteActorDocumentOptions = {},
|
|
635
|
+
): Promise<ActorCacheResult> {
|
|
636
|
+
const { mode = "upsert", publicKey = "allow-keyless" } = options;
|
|
637
|
+
|
|
638
|
+
if (!isSafeRemoteUrl(expectedActorApId)) {
|
|
639
|
+
return { ok: false, reason: "missing_inbox" };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const data = tryParseRemoteActor(rawDocument);
|
|
643
|
+
if (!data) return { ok: false, reason: "invalid_document" };
|
|
644
|
+
if (data.id !== expectedActorApId) {
|
|
645
|
+
return { ok: false, reason: "id_mismatch" };
|
|
646
|
+
}
|
|
647
|
+
if (
|
|
648
|
+
!data.inbox ||
|
|
649
|
+
!isSafeRemoteUrl(data.id) ||
|
|
650
|
+
!isSafeRemoteUrl(data.inbox)
|
|
651
|
+
) {
|
|
652
|
+
return { ok: false, reason: "missing_inbox" };
|
|
653
|
+
}
|
|
654
|
+
if (publicKey === "require-key" && !data.publicKey?.publicKeyPem) {
|
|
655
|
+
return { ok: false, reason: "missing_public_key" };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const fields = {
|
|
659
|
+
...buildActorCacheFields(data),
|
|
660
|
+
rawJson: JSON.stringify(rawDocument),
|
|
661
|
+
};
|
|
662
|
+
const tombstoneActorApId = canonicalRemoteActorTombstoneId(data.id);
|
|
663
|
+
const tombstoneAbsent = () =>
|
|
664
|
+
notExists(
|
|
665
|
+
db
|
|
666
|
+
.select({ actorApId: remoteActorTombstones.actorApId })
|
|
667
|
+
.from(remoteActorTombstones)
|
|
668
|
+
.where(eq(remoteActorTombstones.actorApId, tombstoneActorApId)),
|
|
669
|
+
);
|
|
670
|
+
// Keep the tombstone predicate inside the INSERT statement. A preflight
|
|
671
|
+
// read would leave a check/write gap in which Delete(Actor) could commit and
|
|
672
|
+
// then be undone by this stale network response.
|
|
673
|
+
const candidate = db
|
|
674
|
+
.select({
|
|
675
|
+
apId: sql<string>`${data.id}`.as("ap_id"),
|
|
676
|
+
type: sql<string>`${fields.type}`.as("type"),
|
|
677
|
+
preferredUsername: sql<string | null>`${fields.preferredUsername}`.as(
|
|
678
|
+
"preferred_username",
|
|
679
|
+
),
|
|
680
|
+
name: sql<string | null>`${fields.name}`.as("name"),
|
|
681
|
+
summary: sql<string | null>`${fields.summary}`.as("summary"),
|
|
682
|
+
iconUrl: sql<string | null>`${fields.iconUrl}`.as("icon_url"),
|
|
683
|
+
inbox: sql<string>`${fields.inbox}`.as("inbox"),
|
|
684
|
+
outbox: sql<string | null>`${fields.outbox}`.as("outbox"),
|
|
685
|
+
followersUrl: sql<string | null>`${fields.followersUrl}`.as(
|
|
686
|
+
"followers_url",
|
|
687
|
+
),
|
|
688
|
+
followingUrl: sql<string | null>`${fields.followingUrl}`.as(
|
|
689
|
+
"following_url",
|
|
690
|
+
),
|
|
691
|
+
sharedInbox: sql<string | null>`${fields.sharedInbox}`.as("shared_inbox"),
|
|
692
|
+
publicKeyId: sql<string | null>`${fields.publicKeyId}`.as(
|
|
693
|
+
"public_key_id",
|
|
694
|
+
),
|
|
695
|
+
publicKeyPem: sql<string | null>`${fields.publicKeyPem}`.as(
|
|
696
|
+
"public_key_pem",
|
|
697
|
+
),
|
|
698
|
+
rawJson: sql<string>`${fields.rawJson}`.as("raw_json"),
|
|
699
|
+
lastFetchedAt: sql<string>`${fields.lastFetchedAt}`.as("last_fetched_at"),
|
|
700
|
+
createdAt: sql<string>`datetime('now')`.as("created_at"),
|
|
701
|
+
})
|
|
702
|
+
.from(sql`(SELECT 1) AS actor_cache_candidate`)
|
|
703
|
+
.where(tombstoneAbsent());
|
|
704
|
+
const write =
|
|
705
|
+
mode === "insert"
|
|
706
|
+
? db.insert(actorCache).select(candidate).onConflictDoNothing()
|
|
707
|
+
: db
|
|
708
|
+
.insert(actorCache)
|
|
709
|
+
.select(candidate)
|
|
710
|
+
.onConflictDoUpdate({ target: actorCache.apId, set: fields });
|
|
711
|
+
const clearFailure = db
|
|
712
|
+
.delete(remoteActorFetchFailures)
|
|
713
|
+
.where(
|
|
714
|
+
and(eq(remoteActorFetchFailures.actorApId, data.id), tombstoneAbsent()),
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
await runBatch(db, [
|
|
718
|
+
write as unknown as D1Statement,
|
|
719
|
+
clearFailure as unknown as D1Statement,
|
|
720
|
+
]);
|
|
721
|
+
|
|
722
|
+
if (await isRemoteActorTombstoned(db, data.id)) {
|
|
723
|
+
return { ok: false, reason: "actor_tombstoned" };
|
|
724
|
+
}
|
|
725
|
+
const row = await db
|
|
726
|
+
.select()
|
|
727
|
+
.from(actorCache)
|
|
728
|
+
.where(eq(actorCache.apId, data.id))
|
|
729
|
+
.get();
|
|
730
|
+
if (!row) {
|
|
731
|
+
// Delete may have committed after the first tombstone read and removed the
|
|
732
|
+
// row. Recheck so the caller observes terminal deletion, not availability.
|
|
733
|
+
if (await isRemoteActorTombstoned(db, data.id)) {
|
|
734
|
+
return { ok: false, reason: "actor_tombstoned" };
|
|
735
|
+
}
|
|
736
|
+
return { ok: false, reason: "fetch_failed" };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return { ok: true, data, row };
|
|
740
|
+
}
|
|
741
|
+
|
|
148
742
|
/**
|
|
149
743
|
* Fetch a remote actor document, validate it, and upsert it into
|
|
150
744
|
* `actor_cache` using the single canonical column set. Returns a discriminated
|
|
@@ -171,7 +765,7 @@ export async function fetchAndUpsertActorCache(
|
|
|
171
765
|
return { ok: false, reason: "missing_inbox" };
|
|
172
766
|
}
|
|
173
767
|
|
|
174
|
-
let
|
|
768
|
+
let raw: unknown;
|
|
175
769
|
try {
|
|
176
770
|
const headers: Record<string, string> = {
|
|
177
771
|
Accept: "application/activity+json, application/ld+json",
|
|
@@ -189,50 +783,23 @@ export async function fetchAndUpsertActorCache(
|
|
|
189
783
|
headers,
|
|
190
784
|
timeout,
|
|
191
785
|
});
|
|
192
|
-
if (!res.ok)
|
|
193
|
-
|
|
194
|
-
|
|
786
|
+
if (!res.ok) {
|
|
787
|
+
return {
|
|
788
|
+
ok: false,
|
|
789
|
+
reason: "fetch_not_ok",
|
|
790
|
+
status: res.status,
|
|
791
|
+
retryAfterSeconds: parseRetryAfterSeconds(
|
|
792
|
+
res.headers.get("retry-after"),
|
|
793
|
+
Date.now(),
|
|
794
|
+
),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
raw = await res.json();
|
|
195
798
|
} catch {
|
|
196
799
|
return { ok: false, reason: "fetch_failed" };
|
|
197
800
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
!data.inbox ||
|
|
203
|
-
!isSafeRemoteUrl(data.id) ||
|
|
204
|
-
!isSafeRemoteUrl(data.inbox)
|
|
205
|
-
) {
|
|
206
|
-
return { ok: false, reason: "missing_inbox" };
|
|
207
|
-
}
|
|
208
|
-
if (publicKey === "require-key" && !data.publicKey?.publicKeyPem) {
|
|
209
|
-
return { ok: false, reason: "missing_public_key" };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const fields = buildActorCacheFields(data);
|
|
213
|
-
|
|
214
|
-
if (mode === "insert") {
|
|
215
|
-
// Cache-when-absent: leave an existing row untouched. The early-existence
|
|
216
|
-
// check at the call site is best-effort, so two isolates racing the same
|
|
217
|
-
// cold actor can both reach this insert; `onConflictDoNothing` keeps that
|
|
218
|
-
// race-safe instead of throwing a primary-key violation.
|
|
219
|
-
await db
|
|
220
|
-
.insert(actorCache)
|
|
221
|
-
.values({ apId: data.id, ...fields })
|
|
222
|
-
.onConflictDoNothing();
|
|
223
|
-
} else {
|
|
224
|
-
await db
|
|
225
|
-
.insert(actorCache)
|
|
226
|
-
.values({ apId: data.id, ...fields })
|
|
227
|
-
.onConflictDoUpdate({ target: actorCache.apId, set: fields });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const row = await db
|
|
231
|
-
.select()
|
|
232
|
-
.from(actorCache)
|
|
233
|
-
.where(eq(actorCache.apId, data.id))
|
|
234
|
-
.get();
|
|
235
|
-
if (!row) return { ok: false, reason: "fetch_failed" };
|
|
236
|
-
|
|
237
|
-
return { ok: true, data, row };
|
|
801
|
+
return await cacheRemoteActorDocument(db, actorApId, raw, {
|
|
802
|
+
mode,
|
|
803
|
+
publicKey,
|
|
804
|
+
});
|
|
238
805
|
}
|