@oxy.so/federation 1.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.
Files changed (78) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/actorObject.js +216 -0
  5. package/dist/cjs/apContext.js +48 -0
  6. package/dist/cjs/apUri.js +132 -0
  7. package/dist/cjs/httpSignature.js +187 -0
  8. package/dist/cjs/index.js +99 -0
  9. package/dist/cjs/networkIdentity.js +487 -0
  10. package/dist/cjs/node/actorResolver.js +625 -0
  11. package/dist/cjs/node/actorRouter.js +307 -0
  12. package/dist/cjs/node/delivery.js +415 -0
  13. package/dist/cjs/node/identityBridge.js +133 -0
  14. package/dist/cjs/node/inboundDispatch.js +268 -0
  15. package/dist/cjs/node/index.js +63 -0
  16. package/dist/cjs/node/signedFetch.js +122 -0
  17. package/dist/cjs/node/webfingerRouter.js +166 -0
  18. package/dist/cjs/urls.js +55 -0
  19. package/dist/esm/.tsbuildinfo +1 -0
  20. package/dist/esm/actorObject.js +210 -0
  21. package/dist/esm/apContext.js +45 -0
  22. package/dist/esm/apUri.js +126 -0
  23. package/dist/esm/httpSignature.js +179 -0
  24. package/dist/esm/index.js +65 -0
  25. package/dist/esm/networkIdentity.js +472 -0
  26. package/dist/esm/node/actorResolver.js +620 -0
  27. package/dist/esm/node/actorRouter.js +304 -0
  28. package/dist/esm/node/delivery.js +412 -0
  29. package/dist/esm/node/identityBridge.js +130 -0
  30. package/dist/esm/node/inboundDispatch.js +263 -0
  31. package/dist/esm/node/index.js +51 -0
  32. package/dist/esm/node/signedFetch.js +119 -0
  33. package/dist/esm/node/webfingerRouter.js +163 -0
  34. package/dist/esm/urls.js +50 -0
  35. package/dist/types/.tsbuildinfo +1 -0
  36. package/dist/types/actorObject.d.ts +182 -0
  37. package/dist/types/apContext.d.ts +35 -0
  38. package/dist/types/apUri.d.ts +107 -0
  39. package/dist/types/httpSignature.d.ts +113 -0
  40. package/dist/types/index.d.ts +336 -0
  41. package/dist/types/networkIdentity.d.ts +509 -0
  42. package/dist/types/node/actorResolver.d.ts +287 -0
  43. package/dist/types/node/actorRouter.d.ts +108 -0
  44. package/dist/types/node/delivery.d.ts +248 -0
  45. package/dist/types/node/identityBridge.d.ts +84 -0
  46. package/dist/types/node/inboundDispatch.d.ts +156 -0
  47. package/dist/types/node/index.d.ts +51 -0
  48. package/dist/types/node/signedFetch.d.ts +74 -0
  49. package/dist/types/node/webfingerRouter.d.ts +62 -0
  50. package/dist/types/urls.d.ts +55 -0
  51. package/package.json +119 -0
  52. package/src/__tests__/actorObject.test.ts +258 -0
  53. package/src/__tests__/actorResolver.test.ts +252 -0
  54. package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
  55. package/src/__tests__/apUri.test.ts +53 -0
  56. package/src/__tests__/delivery.test.ts +432 -0
  57. package/src/__tests__/federationHost.test.ts +281 -0
  58. package/src/__tests__/httpSignature.test.ts +343 -0
  59. package/src/__tests__/inboundDispatch.test.ts +381 -0
  60. package/src/__tests__/index.test.ts +8 -0
  61. package/src/__tests__/networkIdentity.test.ts +525 -0
  62. package/src/__tests__/routers.test.ts +460 -0
  63. package/src/__tests__/urls.test.ts +26 -0
  64. package/src/actorObject.ts +313 -0
  65. package/src/apContext.ts +45 -0
  66. package/src/apUri.ts +161 -0
  67. package/src/httpSignature.ts +282 -0
  68. package/src/index.ts +419 -0
  69. package/src/networkIdentity.ts +731 -0
  70. package/src/node/actorResolver.ts +839 -0
  71. package/src/node/actorRouter.ts +438 -0
  72. package/src/node/delivery.ts +729 -0
  73. package/src/node/identityBridge.ts +230 -0
  74. package/src/node/inboundDispatch.ts +420 -0
  75. package/src/node/index.ts +136 -0
  76. package/src/node/signedFetch.ts +177 -0
  77. package/src/node/webfingerRouter.ts +226 -0
  78. package/src/urls.ts +71 -0
@@ -0,0 +1,620 @@
1
+ /**
2
+ * Resolution, caching and refresh of remote ActivityPub actors.
3
+ *
4
+ * Extracted behaviour-identically from Mention's `ActorService`. The engine owns
5
+ * the PROTOCOL — webfinger resolution, the signed actor fetch, the redirect /
6
+ * WebFinger fallback, the 410-Gone tombstone, the self-consistency + same-origin
7
+ * guards, and the staleness/refresh policy. Everything app-specific is injected:
8
+ *
9
+ * - the FederatedActor CACHE lives in the app DB, reached through a
10
+ * {@link FederatedActorStore} adapter ("bring your own store" — no data move),
11
+ * - the actor↔Oxy-user bridge is the injected {@link ActorResolverIdentity}
12
+ * (`PUT /users/resolve` + actor-gone archive),
13
+ * - the signed AP fetch + the SSRF-safe WebFinger fetch are injected transports,
14
+ * - remote-text normalization is an injected {@link ActorTextAdapter} (the app's
15
+ * canonical normalizer + sanitizer), so the engine ships no HTML deps.
16
+ *
17
+ * The resolver is generic over the app's stored actor record shape (`TActor`,
18
+ * e.g. Mention's `IFederatedActor`) so callers keep full typing on the returned
19
+ * document.
20
+ */
21
+ import { isSameFederationHost } from '../apUri.js';
22
+ import { readProxyDeclarations, } from '../networkIdentity.js';
23
+ /**
24
+ * Minimum interval between background actor refreshes for the same actor.
25
+ * Prevents refresh storms when a profile is viewed repeatedly in a short window.
26
+ */
27
+ const ACTOR_REFRESH_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
28
+ /** Staleness threshold after which a cached actor is eligible for a background re-fetch. */
29
+ const ACTOR_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
30
+ /** The AP content type asked for on signed actor/collection fetches. */
31
+ const AP_CONTENT_TYPE = 'application/activity+json';
32
+ /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
33
+ const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
34
+ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
35
+ const ERROR_BODY_MAX_BYTES = 4 * 1024;
36
+ async function readBoundedResponseBody(res, maxBytes) {
37
+ const contentLength = res.headers.get('content-length');
38
+ if (contentLength) {
39
+ const declaredBytes = Number(contentLength);
40
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
41
+ await res.body?.cancel().catch(() => { });
42
+ throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
43
+ }
44
+ }
45
+ if (!res.body)
46
+ return '';
47
+ const reader = res.body.getReader();
48
+ const decoder = new TextDecoder('utf-8', { fatal: true });
49
+ let bytesRead = 0;
50
+ let text = '';
51
+ try {
52
+ while (true) {
53
+ const { done, value } = await reader.read();
54
+ if (done)
55
+ break;
56
+ bytesRead += value.byteLength;
57
+ if (bytesRead > maxBytes) {
58
+ await reader.cancel().catch(() => { });
59
+ throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
60
+ }
61
+ text += decoder.decode(value, { stream: true });
62
+ }
63
+ return text + decoder.decode();
64
+ }
65
+ finally {
66
+ reader.releaseLock();
67
+ }
68
+ }
69
+ async function readBoundedJson(res, maxBytes) {
70
+ const body = await readBoundedResponseBody(res, maxBytes);
71
+ // An empty body is a REMOTE's answer, not a parser error. Without this,
72
+ // `JSON.parse('')` throws a SyntaxError whose message names a column number,
73
+ // which is a worse thing to find in a federation log than the fact that the
74
+ // instance sent nothing.
75
+ if (body.length === 0)
76
+ throw new Error('Remote response has no body');
77
+ const value = JSON.parse(body);
78
+ const record = asRecord(value);
79
+ if (!record)
80
+ throw new Error('Remote response is not a JSON object');
81
+ return record;
82
+ }
83
+ function isApActorContentType(type) {
84
+ if (!type)
85
+ return false;
86
+ const base = type.split(';')[0]?.trim().toLowerCase();
87
+ return base === 'application/activity+json' || base === 'application/ld+json';
88
+ }
89
+ function asRecord(value) {
90
+ return value && typeof value === 'object' && !Array.isArray(value)
91
+ ? value
92
+ : null;
93
+ }
94
+ function asString(value) {
95
+ return typeof value === 'string' ? value : undefined;
96
+ }
97
+ function sameOriginUrl(a, b) {
98
+ try {
99
+ const urlA = new URL(a);
100
+ const urlB = new URL(b);
101
+ return urlA.protocol === urlB.protocol
102
+ && urlA.port === urlB.port
103
+ && isSameFederationHost(urlA.hostname, urlB.hostname);
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ function actorPublicKeyIsSelfConsistent(actor, actorId) {
110
+ const publicKey = asRecord(actor.publicKey);
111
+ if (!publicKey)
112
+ return true;
113
+ const publicKeyId = asString(publicKey.id);
114
+ if (publicKeyId && !sameOriginUrl(publicKeyId, actorId))
115
+ return false;
116
+ const owner = asString(publicKey.owner);
117
+ if (owner && owner !== actorId)
118
+ return false;
119
+ return true;
120
+ }
121
+ /**
122
+ * Resolution, caching and refresh of remote ActivityPub actors, over app-provided
123
+ * storage + identity + transports. A class so that internal cross-calls dispatch
124
+ * through the instance (e.g. `fetchRemoteActor` → `this.tombstoneGoneActor`),
125
+ * which keeps them spy-able and overridable in tests.
126
+ */
127
+ export class ActorResolver {
128
+ constructor(config) {
129
+ this.config = config;
130
+ /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
131
+ this.inFlightActorRefreshes = new Set();
132
+ }
133
+ /**
134
+ * Whether an actor URI's host is refused by the instance domain policy. An
135
+ * unparseable URI has no host to check, so it is refused too — the policy is a
136
+ * safety gate and fails closed rather than letting a malformed URI slip past it.
137
+ */
138
+ isBlockedActorUri(actorUri) {
139
+ let host;
140
+ try {
141
+ host = new URL(actorUri).hostname.toLowerCase();
142
+ }
143
+ catch {
144
+ return true;
145
+ }
146
+ return this.config.isBlockedDomain(host);
147
+ }
148
+ acctMatchesActorHost(acct, actorHost) {
149
+ if (!acct)
150
+ return false;
151
+ const domain = this.config.domainFromAcct(acct);
152
+ if (!domain)
153
+ return false;
154
+ return isSameFederationHost(domain, actorHost);
155
+ }
156
+ /**
157
+ * Resolve a WebFinger acct to an ActivityPub actor URI.
158
+ * @param acct - e.g. "alice@mastodon.social" or "@alice@mastodon.social"
159
+ */
160
+ async resolveWebFinger(acct) {
161
+ const cleaned = this.config.normalizeFederatedAcct(acct);
162
+ if (!cleaned)
163
+ return null;
164
+ const domain = this.config.domainFromAcct(cleaned);
165
+ if (!domain)
166
+ return null;
167
+ if (this.config.isBlockedDomain(domain))
168
+ return null;
169
+ const resource = `acct:${cleaned}`;
170
+ const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
171
+ try {
172
+ const data = await this.config.fetchWebFinger(url);
173
+ if (!data)
174
+ return null;
175
+ const link = data.links?.find((l) => l.rel === 'self' && isApActorContentType(l.type));
176
+ return link?.href || null;
177
+ }
178
+ catch (err) {
179
+ this.config.logger.warn(`WebFinger resolution failed for ${acct}:`, err);
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * Fetch and store/update a remote ActivityPub actor by URI.
185
+ *
186
+ * @param actorUri - the remote actor URI to fetch.
187
+ * @param forceAvatarRefresh - when true, tell Oxy's `PUT /users/resolve` to
188
+ * re-download and replace the federated avatar even if it already has a stored
189
+ * file ID. Pass `true` from refresh paths and `false` for first-time creation.
190
+ */
191
+ async fetchRemoteActor(actorUri, forceAvatarRefresh = false, acctHint) {
192
+ // A WebFinger fallback may resolve the stored URI to a different canonical one;
193
+ // track that here rather than reassigning the parameter (the stored row is
194
+ // keyed by the fetched `actor.id`, so a redirect only affects log context).
195
+ let currentUri = actorUri;
196
+ try {
197
+ // Reject our own/blocked domains before any network I/O. A malformed URI
198
+ // throws here and is handled by the catch below.
199
+ const requestedHost = new URL(currentUri).hostname.toLowerCase();
200
+ if (this.config.isBlockedDomain(requestedHost)) {
201
+ this.config.logger.info(`[FedSync] fetchRemoteActor skipping own/blocked domain ${requestedHost} for ${currentUri}`);
202
+ return null;
203
+ }
204
+ const canonicalAcctHint = this.config.normalizeFederatedAcct(acctHint);
205
+ // Use signed fetch for servers that enforce authorized fetch (e.g., Threads)
206
+ let res = await this.config.signedFetch(currentUri, AP_CONTENT_TYPE);
207
+ if (!res.ok) {
208
+ // A definitive 410 Gone is authoritative: tombstone and stop — do NOT fall
209
+ // through to the WebFinger fallback (which recovers a STALE/wrong URI on a
210
+ // transient failure, not a permanent removal). Only 410 does this.
211
+ if (res.status === 410) {
212
+ this.config.logger.info(`[FedSync] fetchRemoteActor 410 Gone for ${currentUri} — tombstoning actor`);
213
+ await this.tombstoneGoneActor(currentUri);
214
+ return null;
215
+ }
216
+ const body = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
217
+ this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} ${res.statusText} for ${currentUri} body=${body.slice(0, 500)}`);
218
+ // If direct fetch failed, try WebFinger to resolve the canonical actor URI.
219
+ // Some servers (e.g., Threads) use numeric IDs in AP URIs that differ from
220
+ // the username-based URI we may have stored.
221
+ const parsed = new URL(currentUri);
222
+ const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
223
+ const acct = canonicalAcctHint
224
+ || (pathUsername ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`) : undefined);
225
+ if (acct) {
226
+ this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
227
+ const resolved = await this.resolveWebFinger(acct);
228
+ if (resolved && resolved !== currentUri) {
229
+ this.config.logger.info(`[FedSync] WebFinger resolved ${acct} → ${resolved}`);
230
+ res = await this.config.signedFetch(resolved, AP_CONTENT_TYPE);
231
+ if (res.ok) {
232
+ currentUri = resolved;
233
+ }
234
+ else {
235
+ // A 410 on the WebFinger-RESOLVED URI is just as definitive. Tombstone
236
+ // against the stored URI (not reassigned on this branch).
237
+ if (res.status === 410) {
238
+ this.config.logger.info(`[FedSync] fetchRemoteActor 410 Gone for resolved ${resolved} — tombstoning actor ${currentUri}`);
239
+ await this.tombstoneGoneActor(currentUri);
240
+ return null;
241
+ }
242
+ const body2 = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
243
+ this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} for resolved ${resolved} body=${body2.slice(0, 500)}`);
244
+ return null;
245
+ }
246
+ }
247
+ else {
248
+ this.config.logger.info(`[FedSync] WebFinger returned ${resolved ?? 'null'} for ${acct}`);
249
+ return null;
250
+ }
251
+ }
252
+ else {
253
+ return null;
254
+ }
255
+ }
256
+ const actor = await readBoundedJson(res, ACTOR_BODY_MAX_BYTES);
257
+ const actorId = asString(actor.id);
258
+ const actorInbox = asString(actor.inbox);
259
+ if (!actorId || !actorInbox) {
260
+ this.config.logger.info(`[FedSync] fetchRemoteActor missing fields for ${currentUri}: id=${!!actor.id} inbox=${!!actor.inbox} type=${String(actor.type)} keys=${Object.keys(actor).join(',')}`);
261
+ return null;
262
+ }
263
+ if (!sameOriginUrl(currentUri, actorId)) {
264
+ this.config.logger.warn(`[FedSync] rejecting actor ${currentUri}: fetched URI is not authoritative for claimed id ${actorId}`);
265
+ return null;
266
+ }
267
+ if (!actorPublicKeyIsSelfConsistent(actor, actorId)) {
268
+ this.config.logger.warn(`[FedSync] rejecting actor ${currentUri}: publicKey is not self-consistent for claimed id ${actorId}`);
269
+ return null;
270
+ }
271
+ const actorHost = new URL(actorId).hostname.toLowerCase();
272
+ const username = this.config.text.inlineField(actor.preferredUsername)
273
+ || this.config.text.inlineField(actor.name)
274
+ || 'unknown';
275
+ const actorWebfinger = typeof actor.webfinger === 'string'
276
+ ? this.config.normalizeFederatedAcct(actor.webfinger)
277
+ : undefined;
278
+ const verifiedAcctHint = this.acctMatchesActorHost(canonicalAcctHint, actorHost)
279
+ ? canonicalAcctHint
280
+ : undefined;
281
+ const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
282
+ ? actorWebfinger
283
+ : undefined;
284
+ const acct = verifiedAcctHint
285
+ || verifiedActorWebfinger
286
+ || this.config.normalizeFederatedAcct(`${username}@${actorHost}`)
287
+ || `${username.toLowerCase()}@${actorHost}`;
288
+ const domain = this.config.domainFromAcct(acct) || actorHost;
289
+ // Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
290
+ // can differ from the originally-requested URI host the early guard screened.
291
+ if (this.config.isBlockedDomain(domain) || this.config.isBlockedDomain(actorHost)) {
292
+ this.config.logger.info(`[FedSync] fetchRemoteActor blocked domain ${domain} actorHost=${actorHost} for ${currentUri}`);
293
+ return null;
294
+ }
295
+ const actorEndpoints = asRecord(actor.endpoints);
296
+ const actorPublicKey = asRecord(actor.publicKey);
297
+ // Fetch collection counts (followers, following, posts) in parallel
298
+ const [followersCount, followingCount, postsCount] = await Promise.all([
299
+ this.fetchCollectionCount(asString(actor.followers)),
300
+ this.fetchCollectionCount(asString(actor.following)),
301
+ this.fetchCollectionCount(asString(actor.outbox)),
302
+ ]);
303
+ // Extract profile fields (PropertyValue attachments). Sanitize BEFORE
304
+ // normalizing: the canonical normalizer collapses whitespace, it never
305
+ // strips markup — so the sanitizer must run first, on the raw value.
306
+ const fields = [];
307
+ if (Array.isArray(actor.attachment)) {
308
+ for (const att of actor.attachment) {
309
+ const attRecord = asRecord(att);
310
+ if (!attRecord || attRecord.type !== 'PropertyValue')
311
+ continue;
312
+ const fieldName = this.config.text.inlineField(attRecord.name);
313
+ const fieldValue = typeof attRecord.value === 'string'
314
+ ? this.config.text.sanitizeFieldValue(attRecord.value)
315
+ : '';
316
+ if (!fieldName || !fieldValue)
317
+ continue;
318
+ fields.push({
319
+ name: fieldName,
320
+ value: fieldValue,
321
+ verifiedAt: attRecord.verifiedAt ? new Date(String(attRecord.verifiedAt)) : undefined,
322
+ });
323
+ }
324
+ }
325
+ const avatarUrl = this.config.firstStringUrl(actor.icon);
326
+ const headerUrl = this.config.firstStringUrl(actor.image);
327
+ // `summary` is the actor's bio — a BODY, so its line breaks are the author's
328
+ // and must survive; `htmlToPlainText` normalizes it as multiline.
329
+ const summary = typeof actor.summary === 'string' ? this.config.text.htmlToPlainText(actor.summary) : '';
330
+ // The display name is one line. Entity-decode FIRST (an encoded `
` or
331
+ // ` ` only becomes whitespace once decoded), THEN collapse.
332
+ const rawDisplayName = typeof actor.name === 'string' ? actor.name : '';
333
+ const displayName = this.config.text.inlineDisplayName(rawDisplayName) || username;
334
+ const alsoKnownAs = Array.isArray(actor.alsoKnownAs)
335
+ ? actor.alsoKnownAs.filter((v) => typeof v === 'string')
336
+ : undefined;
337
+ // The IDENTITY the actor is stored under in Oxy, which is not necessarily the
338
+ // host it was fetched from — see `DeriveNetworkIdentity`. Everything below
339
+ // that addresses the actor over the PROTOCOL (`acct`, `uri`, `domain`) is
340
+ // deliberately left alone.
341
+ const networkIdentity = this.resolveNetworkIdentity({
342
+ host: actorHost,
343
+ acct,
344
+ preferredUsername: username,
345
+ actorUri: actorId,
346
+ actorType: asString(actor.type) || 'Person',
347
+ alsoKnownAs: alsoKnownAs ?? [],
348
+ fields,
349
+ // FEP-fffd: an actor's own machine-readable statement of what it proxies.
350
+ // Parsed here so a derivation rule never re-reads the raw document — and
351
+ // deliberately only ever CONSULTED by a reviewed entry, since every field
352
+ // in it is asserted by the untrusted actor itself.
353
+ proxyOf: readProxyDeclarations(actor.proxyOf),
354
+ bio: summary,
355
+ });
356
+ const identityUsername = networkIdentity?.federatedUsername ?? acct;
357
+ const identityDomain = networkIdentity?.instanceDomain ?? domain;
358
+ // Qualified HERE, at the one point both writes below read from: the stored
359
+ // row's `summary` and the Oxy profile's `bio` are the same value, so they
360
+ // cannot drift into disagreeing about what the actor said.
361
+ const resolvedBio = networkIdentity?.bio ?? summary;
362
+ const identityBio = this.config.text.qualifyHandles
363
+ ? this.config.text.qualifyHandles(resolvedBio, identityDomain)
364
+ : resolvedBio;
365
+ const update = {
366
+ protocol: 'activitypub',
367
+ uri: actorId,
368
+ username,
369
+ domain,
370
+ acct,
371
+ summary: identityBio,
372
+ avatarUrl,
373
+ headerUrl,
374
+ inboxUrl: actorInbox,
375
+ outboxUrl: asString(actor.outbox) || undefined,
376
+ sharedInboxUrl: asString(actorEndpoints?.sharedInbox) || undefined,
377
+ followersUrl: asString(actor.followers) || undefined,
378
+ followingUrl: asString(actor.following) || undefined,
379
+ publicKeyPem: asString(actorPublicKey?.publicKeyPem) || undefined,
380
+ publicKeyId: asString(actorPublicKey?.id) || undefined,
381
+ type: asString(actor.type) || 'Person',
382
+ manuallyApprovesFollowers: actor.manuallyApprovesFollowers === true,
383
+ discoverable: actor.discoverable !== false,
384
+ memorial: actor.memorial === true,
385
+ suspended: actor.suspended === true,
386
+ fields,
387
+ featuredUrl: asString(actor.featured) || undefined,
388
+ featuredTagsUrl: asString(actor.featuredTags) || undefined,
389
+ alsoKnownAs,
390
+ networkAcct: networkIdentity?.federatedUsername,
391
+ remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
392
+ followersCount,
393
+ followingCount,
394
+ postsCount,
395
+ lastFetchedAt: new Date(),
396
+ };
397
+ const fedActor = await this.config.store.upsertActor(actorId, update);
398
+ // Always upsert into Oxy so profile changes (avatar, name, bio) are synced.
399
+ // The identity bridge creates the federated Oxy user if it does not exist,
400
+ // updates it when changed, and mirrors the banner. This connector then stamps
401
+ // its own actor row with the resolved id.
402
+ if (fedActor) {
403
+ try {
404
+ const normalized = {
405
+ network: 'activitypub',
406
+ externalId: actorId,
407
+ handle: acct,
408
+ // For an ordinary AP actor the acct IS the canonical `user@domain` Oxy
409
+ // username and `domain` is its instance host — both verified above. For
410
+ // a BRIDGED actor these two carry the re-labelled network identity
411
+ // instead, while `handle` above stays the protocol address.
412
+ federatedUsername: identityUsername,
413
+ instanceDomain: identityDomain,
414
+ displayName,
415
+ avatarUrl,
416
+ bannerUrl: headerUrl,
417
+ // Sent even when EMPTY, and that is the whole point. oxy-api writes
418
+ // this field only when it receives a string, so omitting it means
419
+ // "keep whatever you already stored" — which is exactly wrong for the
420
+ // two ways a bio legitimately becomes empty: a bridged actor whose
421
+ // bio was nothing but the bridge's boilerplate (stripped above), and
422
+ // any actor who simply deleted theirs upstream. Coalescing the empty
423
+ // string away made both of those unrepresentable, so the stale text
424
+ // survived every later refresh with nothing in the logs.
425
+ bio: identityBio,
426
+ followersCount,
427
+ followingCount,
428
+ postsCount,
429
+ oxyUserId: fedActor.oxyUserId ?? undefined,
430
+ };
431
+ const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
432
+ if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
433
+ await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
434
+ }
435
+ }
436
+ catch (resolveErr) {
437
+ this.config.logger.warn(`Failed to resolve Oxy user for ${currentUri}:`, resolveErr);
438
+ }
439
+ }
440
+ return fedActor;
441
+ }
442
+ catch (err) {
443
+ this.config.logger.warn(`Failed to fetch remote actor ${currentUri}:`, err);
444
+ return null;
445
+ }
446
+ }
447
+ /**
448
+ * Run the app's {@link DeriveNetworkIdentity} hook and REFUSE any result the
449
+ * identity bridge could not bind.
450
+ *
451
+ * oxy-api binds a federated username to its domain, so a `federatedUsername`
452
+ * that does not end with `@${instanceDomain}` would be rejected downstream — or
453
+ * worse, mint an identity under a domain it does not name. Validating here means
454
+ * no app can produce that shape, and a hook that gets it wrong degrades to the
455
+ * actor's real protocol acct (the pre-hook behaviour) instead of losing the
456
+ * actor. The refusal is logged: it is a bug in the app's rule, not a normal
457
+ * outcome, and it must not pass silently.
458
+ */
459
+ resolveNetworkIdentity(candidate) {
460
+ const derived = this.config.deriveNetworkIdentity?.(candidate);
461
+ if (!derived)
462
+ return undefined;
463
+ const domain = derived.instanceDomain.trim().toLowerCase();
464
+ const federatedUsername = derived.federatedUsername.trim().toLowerCase();
465
+ // Everything before the FIRST `@` is the local part; the whole value must then
466
+ // be exactly `<local>@<domain>`. Stated as one equality rather than a list of
467
+ // separate shape checks, so a value that is malformed in a way nobody thought
468
+ // of — a second `@`, a missing one, a different separator — fails by default
469
+ // instead of needing its own clause.
470
+ const atIndex = federatedUsername.indexOf('@');
471
+ const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
472
+ if (domain.length === 0
473
+ || localPart.length === 0
474
+ || federatedUsername !== `${localPart}@${domain}`) {
475
+ this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: `
476
+ + `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`);
477
+ return undefined;
478
+ }
479
+ return { federatedUsername, instanceDomain: domain, bio: derived.bio };
480
+ }
481
+ /**
482
+ * Tombstone a remote actor that returned a definitive 410 Gone. Marks the stored
483
+ * actor suspended (via the store) and, when it links to an Oxy identity, asks
484
+ * oxy-api to archive it so it drops out of search.
485
+ *
486
+ * Best-effort and fail-soft: neither the store write nor the Oxy archive call is
487
+ * allowed to throw out of the caller. Idempotent.
488
+ */
489
+ async tombstoneGoneActor(actorUri) {
490
+ try {
491
+ const actor = await this.config.store.tombstoneActor(actorUri);
492
+ if (!actor) {
493
+ this.config.logger.info(`[FedSync] 410 Gone for ${actorUri} — no stored actor row to tombstone`);
494
+ return;
495
+ }
496
+ this.config.logger.info(`[FedSync] tombstoned gone actor ${actorUri} (suspended)`);
497
+ if (actor.oxyUserId) {
498
+ const outcome = await this.config.identity.reportActorGone(actor.oxyUserId);
499
+ this.config.logger.info(`[FedSync] actor-gone report for ${actorUri} (oxyUserId ${actor.oxyUserId}) → ${outcome}`);
500
+ }
501
+ }
502
+ catch (err) {
503
+ this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
504
+ }
505
+ }
506
+ /** Fetch the totalItems count from an ActivityPub collection URL. */
507
+ async fetchCollectionCount(url) {
508
+ if (!url)
509
+ return 0;
510
+ try {
511
+ const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
512
+ if (!res.ok)
513
+ return 0;
514
+ const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
515
+ return typeof col.totalItems === 'number' ? col.totalItems : 0;
516
+ }
517
+ catch {
518
+ return 0;
519
+ }
520
+ }
521
+ /**
522
+ * Get a cached actor or fetch if missing/stale (>24h).
523
+ *
524
+ * Never blocks on remote network I/O when a cached actor already exists: a stale
525
+ * cached actor is returned immediately and a background refresh is enqueued. Only
526
+ * a completely missing actor triggers a blocking fetch.
527
+ */
528
+ async getOrFetchActor(actorUri) {
529
+ // The domain policy governs BOTH branches below, not just the fetching one.
530
+ // `fetchRemoteActor` refuses a blocked host, but the cache hit above it used to
531
+ // return early — so for any instance we had ever stored an actor for (i.e. every
532
+ // instance that has ever reached us), blocking its domain changed nothing. That
533
+ // made the blocklist inert for exactly the hosts it is added for.
534
+ if (this.isBlockedActorUri(actorUri)) {
535
+ this.config.logger.info(`[FedSync] getOrFetchActor refusing own/blocked domain for ${actorUri}`);
536
+ return null;
537
+ }
538
+ const existing = await this.config.store.findActorByUri(actorUri);
539
+ if (existing) {
540
+ const isStale = !existing.lastFetchedAt || Date.now() - existing.lastFetchedAt.getTime() > ACTOR_STALE_MS;
541
+ if (isStale) {
542
+ // Refresh in the background — never block the caller on remote I/O.
543
+ this.refreshActorInBackground(actorUri, existing);
544
+ }
545
+ return existing;
546
+ }
547
+ return this.fetchRemoteActor(actorUri);
548
+ }
549
+ /**
550
+ * Enqueue a fire-and-forget full-actor refresh. Safe to call on a client request
551
+ * path: it returns synchronously and the fetch runs detached. Guards against
552
+ * refresh storms (in-flight dedup + a recency skip unless the profile is
553
+ * incomplete). The avatar refresh is forced only when the actor already exists.
554
+ */
555
+ refreshActorInBackground(actorUri, existing) {
556
+ if (!this.config.federationEnabled)
557
+ return;
558
+ if (this.inFlightActorRefreshes.has(actorUri))
559
+ return;
560
+ const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
561
+ const lastFetchedMs = existing?.lastFetchedAt?.getTime();
562
+ const refreshedRecently = typeof lastFetchedMs === 'number'
563
+ && Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
564
+ // Skip if we refreshed recently AND the cached profile is already complete.
565
+ if (refreshedRecently && !missingProfile)
566
+ return;
567
+ // Force avatar re-download only when the actor already exists (refresh).
568
+ const forceAvatarRefresh = Boolean(existing);
569
+ this.inFlightActorRefreshes.add(actorUri);
570
+ void (async () => {
571
+ try {
572
+ await this.fetchRemoteActor(actorUri, forceAvatarRefresh, existing?.acct);
573
+ }
574
+ catch (err) {
575
+ const message = err instanceof Error ? err.message : String(err);
576
+ this.config.logger.warn(`[FedSync] background actor refresh failed for ${actorUri}: ${message}`);
577
+ }
578
+ finally {
579
+ this.inFlightActorRefreshes.delete(actorUri);
580
+ }
581
+ })();
582
+ }
583
+ /**
584
+ * Resolve a remote actor URI to its listable Oxy user id. Returns null when the
585
+ * actor cannot be resolved to an Oxy user — callers must then skip.
586
+ */
587
+ async resolveActorOxyUserId(actorUri) {
588
+ const actor = await this.getOrFetchActor(actorUri);
589
+ return actor?.oxyUserId ?? null;
590
+ }
591
+ /**
592
+ * Fetch a public key by keyId (used for HTTP signature verification).
593
+ *
594
+ * Deliberately NOT domain-policy gated on the cached branch: this answers "what
595
+ * key signs for this keyId", a question about authenticity, not about whether we
596
+ * federate with the answer. Suspending an instance is enforced where the activity
597
+ * is dispatched (`createInboundDispatcher`), so a blocked instance's signature is
598
+ * still evaluated honestly and its activity is then dropped as policy, rather
599
+ * than being reported as a forged signature. The uncached branch still refuses,
600
+ * because resolving it would mean network I/O toward a blocked host.
601
+ */
602
+ async fetchPublicKey(keyId) {
603
+ // keyId is typically the actor URI with #main-key appended
604
+ const actorUri = keyId.replace(/#.*$/, '');
605
+ // Check local cache first
606
+ const cached = await this.config.store.findActorByPublicKeyId(keyId);
607
+ if (cached?.publicKeyPem) {
608
+ return { publicKeyPem: cached.publicKeyPem, actorUri: cached.uri };
609
+ }
610
+ // Fetch the actor to get the public key (uses 24h cache)
611
+ const actor = await this.getOrFetchActor(actorUri);
612
+ if (!actor?.publicKeyPem)
613
+ return null;
614
+ return { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri };
615
+ }
616
+ }
617
+ /** Build the remote-actor resolver from an app's storage + identity + transports. */
618
+ export function createActorResolver(config) {
619
+ return new ActorResolver(config);
620
+ }