@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,839 @@
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
+
22
+ import { canonicalFederationHost, isSameFederationHost } from '../apUri';
23
+ import {
24
+ readProxyDeclarations,
25
+ type DeriveNetworkIdentity,
26
+ type NetworkIdentity,
27
+ type NetworkIdentityCandidate,
28
+ } from '../networkIdentity';
29
+ import type { NormalizedExternalActor } from '../index';
30
+ import type { SignedFetch } from './signedFetch';
31
+ import type { ReportActorGoneOutcome } from './identityBridge';
32
+
33
+ /**
34
+ * Minimum interval between background actor refreshes for the same actor.
35
+ * Prevents refresh storms when a profile is viewed repeatedly in a short window.
36
+ */
37
+ const ACTOR_REFRESH_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
38
+
39
+ /** Staleness threshold after which a cached actor is eligible for a background re-fetch. */
40
+ const ACTOR_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
41
+
42
+ /** The AP content type asked for on signed actor/collection fetches. */
43
+ const AP_CONTENT_TYPE = 'application/activity+json';
44
+
45
+ /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
46
+ const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
47
+ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
48
+ const ERROR_BODY_MAX_BYTES = 4 * 1024;
49
+
50
+ async function readBoundedResponseBody(res: Response, maxBytes: number): Promise<string> {
51
+ const contentLength = res.headers.get('content-length');
52
+ if (contentLength) {
53
+ const declaredBytes = Number(contentLength);
54
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
55
+ await res.body?.cancel().catch(() => {});
56
+ throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
57
+ }
58
+ }
59
+
60
+ if (!res.body) return '';
61
+
62
+ const reader = res.body.getReader();
63
+ const decoder = new TextDecoder('utf-8', { fatal: true });
64
+ let bytesRead = 0;
65
+ let text = '';
66
+ try {
67
+ while (true) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ bytesRead += value.byteLength;
71
+ if (bytesRead > maxBytes) {
72
+ await reader.cancel().catch(() => {});
73
+ throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
74
+ }
75
+ text += decoder.decode(value, { stream: true });
76
+ }
77
+ return text + decoder.decode();
78
+ } finally {
79
+ reader.releaseLock();
80
+ }
81
+ }
82
+
83
+ async function readBoundedJson(res: Response, maxBytes: number): Promise<Record<string, unknown>> {
84
+ const body = await readBoundedResponseBody(res, maxBytes);
85
+ // An empty body is a REMOTE's answer, not a parser error. Without this,
86
+ // `JSON.parse('')` throws a SyntaxError whose message names a column number,
87
+ // which is a worse thing to find in a federation log than the fact that the
88
+ // instance sent nothing.
89
+ if (body.length === 0) throw new Error('Remote response has no body');
90
+ const value: unknown = JSON.parse(body);
91
+ const record = asRecord(value);
92
+ if (!record) throw new Error('Remote response is not a JSON object');
93
+ return record;
94
+ }
95
+
96
+ function isApActorContentType(type: string | undefined): boolean {
97
+ if (!type) return false;
98
+ const base = type.split(';')[0]?.trim().toLowerCase();
99
+ return base === 'application/activity+json' || base === 'application/ld+json';
100
+ }
101
+
102
+ /** The minimal fields the resolver reads off / writes to a stored actor record. */
103
+ export interface FederatedActorRecordBase {
104
+ _id?: unknown;
105
+ uri: string;
106
+ acct?: string;
107
+ oxyUserId?: string | null;
108
+ avatarUrl?: string;
109
+ headerUrl?: string;
110
+ publicKeyPem?: string;
111
+ lastFetchedAt?: Date | null;
112
+ }
113
+
114
+ /** A verified profile field (PropertyValue) stored on the actor cache. */
115
+ export interface FederatedActorField {
116
+ name: string;
117
+ value: string;
118
+ verifiedAt?: Date;
119
+ }
120
+
121
+ /** The full write shape the resolver upserts into the actor cache. */
122
+ export interface FederatedActorUpsert {
123
+ protocol: 'activitypub';
124
+ uri: string;
125
+ username: string;
126
+ domain: string;
127
+ acct: string;
128
+ summary: string;
129
+ avatarUrl?: string;
130
+ headerUrl?: string;
131
+ inboxUrl?: string;
132
+ outboxUrl?: string;
133
+ sharedInboxUrl?: string;
134
+ followersUrl?: string;
135
+ followingUrl?: string;
136
+ publicKeyPem?: string;
137
+ publicKeyId?: string;
138
+ type: string;
139
+ manuallyApprovesFollowers: boolean;
140
+ discoverable: boolean;
141
+ memorial: boolean;
142
+ suspended: boolean;
143
+ fields: FederatedActorField[];
144
+ featuredUrl?: string;
145
+ featuredTagsUrl?: string;
146
+ alsoKnownAs?: string[];
147
+ /**
148
+ * The `<handle>@<network-domain>` identity this actor was re-labelled onto, when
149
+ * it came from a bridge; absent for the ordinary actor whose identity is simply
150
+ * its acct.
151
+ *
152
+ * Persisted rather than re-derived on demand because it is the key two rows are
153
+ * the SAME PERSON on: the same X account mirrored by two different bridges
154
+ * produces two actor rows with different URIs and different accts, and this is
155
+ * the only field on which they match. An app that de-duplicates bridged
156
+ * identities queries it; one that does not can ignore it.
157
+ */
158
+ networkAcct?: string;
159
+ remoteCreatedAt?: Date;
160
+ followersCount: number;
161
+ followingCount: number;
162
+ postsCount: number;
163
+ lastFetchedAt: Date;
164
+ }
165
+
166
+ /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
167
+ export interface FederatedActorStore<TActor extends FederatedActorRecordBase> {
168
+ /** Look up a cached actor by its protocol URI. */
169
+ findActorByUri(uri: string): Promise<TActor | null>;
170
+ /** Upsert (create-or-update) the actor cache row keyed by `uri`. */
171
+ upsertActor(uri: string, update: FederatedActorUpsert): Promise<TActor | null>;
172
+ /** Look up a cached actor by its `publicKey.id` (HTTP-signature key resolution). */
173
+ findActorByPublicKeyId(keyId: string): Promise<Pick<TActor, 'uri' | 'publicKeyPem'> | null>;
174
+ /** Stamp the resolved Oxy user id onto an actor row (identified by its `_id`). */
175
+ setActorOxyUserId(actorId: unknown, oxyUserId: string): Promise<void>;
176
+ /**
177
+ * Tombstone a permanently-gone actor (mark it suspended) and return its linked
178
+ * Oxy user id (or null when no row matched).
179
+ */
180
+ tombstoneActor(uri: string): Promise<{ oxyUserId?: string | null } | null>;
181
+ }
182
+
183
+ /** The identity-bridge subset the actor resolver depends on. */
184
+ export interface ActorResolverIdentity {
185
+ resolveExternalUser(
186
+ actor: NormalizedExternalActor,
187
+ opts?: { forceAvatarRefresh?: boolean },
188
+ ): Promise<string | null>;
189
+ reportActorGone(oxyUserId: string): Promise<ReportActorGoneOutcome>;
190
+ }
191
+
192
+ /**
193
+ * App-supplied normalization of remote actor text. The engine owns WHICH fields
194
+ * to read and the order; the app owns HOW to normalize (its canonical whitespace
195
+ * normalizer + HTML sanitizer), so the engine ships no HTML/entity dependency.
196
+ */
197
+ export interface ActorTextAdapter {
198
+ /** One-line field (preferredUsername / name / PropertyValue name); '' for non-strings. */
199
+ inlineField(value: unknown): string;
200
+ /** Entity-decode + inline-normalize a display name. */
201
+ inlineDisplayName(raw: string): string;
202
+ /** Sanitize (safe inline markup only) + inline-normalize a PropertyValue html value. */
203
+ sanitizeFieldValue(html: string): string;
204
+ /** Multiline HTML → plain text (the actor bio/summary). */
205
+ htmlToPlainText(html: string): string;
206
+ /**
207
+ * Qualify the bare `@handle`s an actor wrote in its own bio with the network
208
+ * they belong to — `@openai` on an X-relabelled actor means `@openai@x.com`.
209
+ *
210
+ * A handle is only meaningful beside the network it was written on, and that
211
+ * context is exactly what is lost when the text crosses over: copied verbatim,
212
+ * `@openai` reads on the receiving server as a LOCAL name, pointing readers at
213
+ * whoever holds it there.
214
+ *
215
+ * OPTIONAL, and the engine does not care whether an app supplies it: the rule
216
+ * for what may be a handle is the app's (Mention scans with the same entity
217
+ * scanner its composer and renderer use, so a URL's `@handle`, an email and an
218
+ * already-qualified handle are all left alone by construction). An app that
219
+ * omits it gets the previous behaviour exactly.
220
+ *
221
+ * Applied ONCE, where the bio is settled — so the stored actor row and the Oxy
222
+ * profile cannot disagree, and no renderer is left to re-derive it.
223
+ */
224
+ qualifyHandles?(text: string, instanceDomain: string): string;
225
+ }
226
+
227
+ /** A parsed WebFinger JRD (only the `links` we read). */
228
+ export interface WebFingerJrd {
229
+ links?: Array<{ rel?: string; type?: string; href?: string }>;
230
+ }
231
+
232
+ /**
233
+ * SSRF-safe bounded WebFinger fetch: GET the JRD URL and return the parsed JSON,
234
+ * or `null` on a non-2xx response. MAY throw on a network / parse / size-limit
235
+ * failure — the resolver catches it and treats the resolution as failed.
236
+ */
237
+ export type WebFingerFetch = (url: string) => Promise<WebFingerJrd | null>;
238
+
239
+ /** Minimal logging sink the actor resolver writes to. */
240
+ export interface ActorResolverLogger {
241
+ info(message: string): void;
242
+ warn(message: string, detail?: unknown): void;
243
+ }
244
+
245
+ /** Adapters + config an {@link ActorResolver} is built from. */
246
+ export interface ActorResolverConfig<TActor extends FederatedActorRecordBase> {
247
+ /** Whether federation is enabled (gates background refreshes). */
248
+ federationEnabled: boolean;
249
+ /** Signed AP GET (actor + collection-count fetches). */
250
+ signedFetch: SignedFetch;
251
+ /** SSRF-safe bounded WebFinger fetch. */
252
+ fetchWebFinger: WebFingerFetch;
253
+ /** Per-instance blocked-domain check (own domains + identity apex + configured blocks). */
254
+ isBlockedDomain: (domain: string) => boolean;
255
+ /** Canonicalize a fediverse acct (`user@domain`), or undefined when invalid. */
256
+ normalizeFederatedAcct: (acct: string | undefined) => string | undefined;
257
+ /** Extract the domain from a canonical acct. */
258
+ domainFromAcct: (acct: string) => string | undefined;
259
+ /** Recursively find the first absolute http(s) URL in a value (icon/image). */
260
+ firstStringUrl: (value: unknown) => string | undefined;
261
+ /**
262
+ * Optional re-labelling of a bridged actor onto its real network. Absent means
263
+ * every actor keeps the identity of the host it was fetched from.
264
+ */
265
+ deriveNetworkIdentity?: DeriveNetworkIdentity;
266
+ /** The app's actor cache store. */
267
+ store: FederatedActorStore<TActor>;
268
+ /** The actor↔Oxy-user identity bridge. */
269
+ identity: ActorResolverIdentity;
270
+ /** Remote-text normalization. */
271
+ text: ActorTextAdapter;
272
+ /** Diagnostics sink. */
273
+ logger: ActorResolverLogger;
274
+ }
275
+
276
+ function asRecord(value: unknown): Record<string, unknown> | null {
277
+ return value && typeof value === 'object' && !Array.isArray(value)
278
+ ? (value as Record<string, unknown>)
279
+ : null;
280
+ }
281
+
282
+ function asString(value: unknown): string | undefined {
283
+ return typeof value === 'string' ? value : undefined;
284
+ }
285
+
286
+ function sameOriginUrl(a: string, b: string): boolean {
287
+ try {
288
+ const urlA = new URL(a);
289
+ const urlB = new URL(b);
290
+ return urlA.protocol === urlB.protocol
291
+ && urlA.port === urlB.port
292
+ && isSameFederationHost(urlA.hostname, urlB.hostname);
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ function actorPublicKeyIsSelfConsistent(actor: Record<string, unknown>, actorId: string): boolean {
299
+ const publicKey = asRecord(actor.publicKey);
300
+ if (!publicKey) return true;
301
+
302
+ const publicKeyId = asString(publicKey.id);
303
+ if (publicKeyId && !sameOriginUrl(publicKeyId, actorId)) return false;
304
+
305
+ const owner = asString(publicKey.owner);
306
+ if (owner && owner !== actorId) return false;
307
+
308
+ return true;
309
+ }
310
+
311
+ /**
312
+ * Resolution, caching and refresh of remote ActivityPub actors, over app-provided
313
+ * storage + identity + transports. A class so that internal cross-calls dispatch
314
+ * through the instance (e.g. `fetchRemoteActor` → `this.tombstoneGoneActor`),
315
+ * which keeps them spy-able and overridable in tests.
316
+ */
317
+ export class ActorResolver<TActor extends FederatedActorRecordBase> {
318
+ /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
319
+ private readonly inFlightActorRefreshes = new Set<string>();
320
+
321
+ constructor(private readonly config: ActorResolverConfig<TActor>) {}
322
+
323
+ /**
324
+ * Whether an actor URI's host is refused by the instance domain policy. An
325
+ * unparseable URI has no host to check, so it is refused too — the policy is a
326
+ * safety gate and fails closed rather than letting a malformed URI slip past it.
327
+ */
328
+ private isBlockedActorUri(actorUri: string): boolean {
329
+ let host: string;
330
+ try {
331
+ host = new URL(actorUri).hostname.toLowerCase();
332
+ } catch {
333
+ return true;
334
+ }
335
+ return this.config.isBlockedDomain(host);
336
+ }
337
+
338
+ private acctMatchesActorHost(acct: string | undefined, actorHost: string): acct is string {
339
+ if (!acct) return false;
340
+ const domain = this.config.domainFromAcct(acct);
341
+ if (!domain) return false;
342
+ return isSameFederationHost(domain, actorHost);
343
+ }
344
+
345
+ /**
346
+ * Resolve a WebFinger acct to an ActivityPub actor URI.
347
+ * @param acct - e.g. "alice@mastodon.social" or "@alice@mastodon.social"
348
+ */
349
+ async resolveWebFinger(acct: string): Promise<string | null> {
350
+ const cleaned = this.config.normalizeFederatedAcct(acct);
351
+ if (!cleaned) return null;
352
+
353
+ const domain = this.config.domainFromAcct(cleaned);
354
+ if (!domain) return null;
355
+ if (this.config.isBlockedDomain(domain)) return null;
356
+
357
+ const resource = `acct:${cleaned}`;
358
+ const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
359
+
360
+ try {
361
+ const data = await this.config.fetchWebFinger(url);
362
+ if (!data) return null;
363
+ const link = data.links?.find(
364
+ (l) => l.rel === 'self' && isApActorContentType(l.type),
365
+ );
366
+ return link?.href || null;
367
+ } catch (err) {
368
+ this.config.logger.warn(`WebFinger resolution failed for ${acct}:`, err);
369
+ return null;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Fetch and store/update a remote ActivityPub actor by URI.
375
+ *
376
+ * @param actorUri - the remote actor URI to fetch.
377
+ * @param forceAvatarRefresh - when true, tell Oxy's `PUT /users/resolve` to
378
+ * re-download and replace the federated avatar even if it already has a stored
379
+ * file ID. Pass `true` from refresh paths and `false` for first-time creation.
380
+ */
381
+ async fetchRemoteActor(
382
+ actorUri: string,
383
+ forceAvatarRefresh = false,
384
+ acctHint?: string,
385
+ ): Promise<TActor | null> {
386
+ // A WebFinger fallback may resolve the stored URI to a different canonical one;
387
+ // track that here rather than reassigning the parameter (the stored row is
388
+ // keyed by the fetched `actor.id`, so a redirect only affects log context).
389
+ let currentUri = actorUri;
390
+ try {
391
+ // Reject our own/blocked domains before any network I/O. A malformed URI
392
+ // throws here and is handled by the catch below.
393
+ const requestedHost = new URL(currentUri).hostname.toLowerCase();
394
+ if (this.config.isBlockedDomain(requestedHost)) {
395
+ this.config.logger.info(`[FedSync] fetchRemoteActor skipping own/blocked domain ${requestedHost} for ${currentUri}`);
396
+ return null;
397
+ }
398
+
399
+ const canonicalAcctHint = this.config.normalizeFederatedAcct(acctHint);
400
+ // Use signed fetch for servers that enforce authorized fetch (e.g., Threads)
401
+ let res = await this.config.signedFetch(currentUri, AP_CONTENT_TYPE);
402
+ if (!res.ok) {
403
+ // A definitive 410 Gone is authoritative: tombstone and stop — do NOT fall
404
+ // through to the WebFinger fallback (which recovers a STALE/wrong URI on a
405
+ // transient failure, not a permanent removal). Only 410 does this.
406
+ if (res.status === 410) {
407
+ this.config.logger.info(`[FedSync] fetchRemoteActor 410 Gone for ${currentUri} — tombstoning actor`);
408
+ await this.tombstoneGoneActor(currentUri);
409
+ return null;
410
+ }
411
+ const body = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
412
+ this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} ${res.statusText} for ${currentUri} body=${body.slice(0, 500)}`);
413
+
414
+ // If direct fetch failed, try WebFinger to resolve the canonical actor URI.
415
+ // Some servers (e.g., Threads) use numeric IDs in AP URIs that differ from
416
+ // the username-based URI we may have stored.
417
+ const parsed = new URL(currentUri);
418
+ const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
419
+ const acct = canonicalAcctHint
420
+ || (pathUsername ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`) : undefined);
421
+ if (acct) {
422
+ this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
423
+ const resolved = await this.resolveWebFinger(acct);
424
+ if (resolved && resolved !== currentUri) {
425
+ this.config.logger.info(`[FedSync] WebFinger resolved ${acct} → ${resolved}`);
426
+ res = await this.config.signedFetch(resolved, AP_CONTENT_TYPE);
427
+ if (res.ok) {
428
+ currentUri = resolved;
429
+ } else {
430
+ // A 410 on the WebFinger-RESOLVED URI is just as definitive. Tombstone
431
+ // against the stored URI (not reassigned on this branch).
432
+ if (res.status === 410) {
433
+ this.config.logger.info(`[FedSync] fetchRemoteActor 410 Gone for resolved ${resolved} — tombstoning actor ${currentUri}`);
434
+ await this.tombstoneGoneActor(currentUri);
435
+ return null;
436
+ }
437
+ const body2 = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
438
+ this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} for resolved ${resolved} body=${body2.slice(0, 500)}`);
439
+ return null;
440
+ }
441
+ } else {
442
+ this.config.logger.info(`[FedSync] WebFinger returned ${resolved ?? 'null'} for ${acct}`);
443
+ return null;
444
+ }
445
+ } else {
446
+ return null;
447
+ }
448
+ }
449
+
450
+ const actor = await readBoundedJson(res, ACTOR_BODY_MAX_BYTES);
451
+ const actorId = asString(actor.id);
452
+ const actorInbox = asString(actor.inbox);
453
+ if (!actorId || !actorInbox) {
454
+ 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(',')}`);
455
+ return null;
456
+ }
457
+
458
+ if (!sameOriginUrl(currentUri, actorId)) {
459
+ this.config.logger.warn(`[FedSync] rejecting actor ${currentUri}: fetched URI is not authoritative for claimed id ${actorId}`);
460
+ return null;
461
+ }
462
+
463
+ if (!actorPublicKeyIsSelfConsistent(actor, actorId)) {
464
+ this.config.logger.warn(`[FedSync] rejecting actor ${currentUri}: publicKey is not self-consistent for claimed id ${actorId}`);
465
+ return null;
466
+ }
467
+
468
+ const actorHost = new URL(actorId).hostname.toLowerCase();
469
+ const username = this.config.text.inlineField(actor.preferredUsername)
470
+ || this.config.text.inlineField(actor.name)
471
+ || 'unknown';
472
+ const actorWebfinger = typeof actor.webfinger === 'string'
473
+ ? this.config.normalizeFederatedAcct(actor.webfinger)
474
+ : undefined;
475
+ const verifiedAcctHint = this.acctMatchesActorHost(canonicalAcctHint, actorHost)
476
+ ? canonicalAcctHint
477
+ : undefined;
478
+ const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
479
+ ? actorWebfinger
480
+ : undefined;
481
+ const acct = verifiedAcctHint
482
+ || verifiedActorWebfinger
483
+ || this.config.normalizeFederatedAcct(`${username}@${actorHost}`)
484
+ || `${username.toLowerCase()}@${actorHost}`;
485
+ const domain = this.config.domainFromAcct(acct) || actorHost;
486
+ // Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
487
+ // can differ from the originally-requested URI host the early guard screened.
488
+ if (this.config.isBlockedDomain(domain) || this.config.isBlockedDomain(actorHost)) {
489
+ this.config.logger.info(`[FedSync] fetchRemoteActor blocked domain ${domain} actorHost=${actorHost} for ${currentUri}`);
490
+ return null;
491
+ }
492
+
493
+ const actorEndpoints = asRecord(actor.endpoints);
494
+ const actorPublicKey = asRecord(actor.publicKey);
495
+
496
+ // Fetch collection counts (followers, following, posts) in parallel
497
+ const [followersCount, followingCount, postsCount] = await Promise.all([
498
+ this.fetchCollectionCount(asString(actor.followers)),
499
+ this.fetchCollectionCount(asString(actor.following)),
500
+ this.fetchCollectionCount(asString(actor.outbox)),
501
+ ]);
502
+
503
+ // Extract profile fields (PropertyValue attachments). Sanitize BEFORE
504
+ // normalizing: the canonical normalizer collapses whitespace, it never
505
+ // strips markup — so the sanitizer must run first, on the raw value.
506
+ const fields: FederatedActorField[] = [];
507
+ if (Array.isArray(actor.attachment)) {
508
+ for (const att of actor.attachment) {
509
+ const attRecord = asRecord(att);
510
+ if (!attRecord || attRecord.type !== 'PropertyValue') continue;
511
+ const fieldName = this.config.text.inlineField(attRecord.name);
512
+ const fieldValue = typeof attRecord.value === 'string'
513
+ ? this.config.text.sanitizeFieldValue(attRecord.value)
514
+ : '';
515
+ if (!fieldName || !fieldValue) continue;
516
+ fields.push({
517
+ name: fieldName,
518
+ value: fieldValue,
519
+ verifiedAt: attRecord.verifiedAt ? new Date(String(attRecord.verifiedAt)) : undefined,
520
+ });
521
+ }
522
+ }
523
+
524
+ const avatarUrl = this.config.firstStringUrl(actor.icon);
525
+ const headerUrl = this.config.firstStringUrl(actor.image);
526
+ // `summary` is the actor's bio — a BODY, so its line breaks are the author's
527
+ // and must survive; `htmlToPlainText` normalizes it as multiline.
528
+ const summary = typeof actor.summary === 'string' ? this.config.text.htmlToPlainText(actor.summary) : '';
529
+ // The display name is one line. Entity-decode FIRST (an encoded `&#10;` or
530
+ // `&nbsp;` only becomes whitespace once decoded), THEN collapse.
531
+ const rawDisplayName = typeof actor.name === 'string' ? actor.name : '';
532
+ const displayName = this.config.text.inlineDisplayName(rawDisplayName) || username;
533
+
534
+ const alsoKnownAs = Array.isArray(actor.alsoKnownAs)
535
+ ? actor.alsoKnownAs.filter((v): v is string => typeof v === 'string')
536
+ : undefined;
537
+
538
+ // The IDENTITY the actor is stored under in Oxy, which is not necessarily the
539
+ // host it was fetched from — see `DeriveNetworkIdentity`. Everything below
540
+ // that addresses the actor over the PROTOCOL (`acct`, `uri`, `domain`) is
541
+ // deliberately left alone.
542
+ const networkIdentity = this.resolveNetworkIdentity({
543
+ host: actorHost,
544
+ acct,
545
+ preferredUsername: username,
546
+ actorUri: actorId,
547
+ actorType: asString(actor.type) || 'Person',
548
+ alsoKnownAs: alsoKnownAs ?? [],
549
+ fields,
550
+ // FEP-fffd: an actor's own machine-readable statement of what it proxies.
551
+ // Parsed here so a derivation rule never re-reads the raw document — and
552
+ // deliberately only ever CONSULTED by a reviewed entry, since every field
553
+ // in it is asserted by the untrusted actor itself.
554
+ proxyOf: readProxyDeclarations(actor.proxyOf),
555
+ bio: summary,
556
+ });
557
+ const identityUsername = networkIdentity?.federatedUsername ?? acct;
558
+ const identityDomain = networkIdentity?.instanceDomain ?? domain;
559
+ // Qualified HERE, at the one point both writes below read from: the stored
560
+ // row's `summary` and the Oxy profile's `bio` are the same value, so they
561
+ // cannot drift into disagreeing about what the actor said.
562
+ const resolvedBio = networkIdentity?.bio ?? summary;
563
+ const identityBio = this.config.text.qualifyHandles
564
+ ? this.config.text.qualifyHandles(resolvedBio, identityDomain)
565
+ : resolvedBio;
566
+
567
+ const update: FederatedActorUpsert = {
568
+ protocol: 'activitypub',
569
+ uri: actorId,
570
+ username,
571
+ domain,
572
+ acct,
573
+ summary: identityBio,
574
+ avatarUrl,
575
+ headerUrl,
576
+ inboxUrl: actorInbox,
577
+ outboxUrl: asString(actor.outbox) || undefined,
578
+ sharedInboxUrl: asString(actorEndpoints?.sharedInbox) || undefined,
579
+ followersUrl: asString(actor.followers) || undefined,
580
+ followingUrl: asString(actor.following) || undefined,
581
+ publicKeyPem: asString(actorPublicKey?.publicKeyPem) || undefined,
582
+ publicKeyId: asString(actorPublicKey?.id) || undefined,
583
+ type: asString(actor.type) || 'Person',
584
+ manuallyApprovesFollowers: actor.manuallyApprovesFollowers === true,
585
+ discoverable: actor.discoverable !== false,
586
+ memorial: actor.memorial === true,
587
+ suspended: actor.suspended === true,
588
+ fields,
589
+ featuredUrl: asString(actor.featured) || undefined,
590
+ featuredTagsUrl: asString(actor.featuredTags) || undefined,
591
+ alsoKnownAs,
592
+ networkAcct: networkIdentity?.federatedUsername,
593
+ remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
594
+ followersCount,
595
+ followingCount,
596
+ postsCount,
597
+ lastFetchedAt: new Date(),
598
+ };
599
+
600
+ const fedActor = await this.config.store.upsertActor(actorId, update);
601
+
602
+ // Always upsert into Oxy so profile changes (avatar, name, bio) are synced.
603
+ // The identity bridge creates the federated Oxy user if it does not exist,
604
+ // updates it when changed, and mirrors the banner. This connector then stamps
605
+ // its own actor row with the resolved id.
606
+ if (fedActor) {
607
+ try {
608
+ const normalized: NormalizedExternalActor = {
609
+ network: 'activitypub',
610
+ externalId: actorId,
611
+ handle: acct,
612
+ // For an ordinary AP actor the acct IS the canonical `user@domain` Oxy
613
+ // username and `domain` is its instance host — both verified above. For
614
+ // a BRIDGED actor these two carry the re-labelled network identity
615
+ // instead, while `handle` above stays the protocol address.
616
+ federatedUsername: identityUsername,
617
+ instanceDomain: identityDomain,
618
+ displayName,
619
+ avatarUrl,
620
+ bannerUrl: headerUrl,
621
+ // Sent even when EMPTY, and that is the whole point. oxy-api writes
622
+ // this field only when it receives a string, so omitting it means
623
+ // "keep whatever you already stored" — which is exactly wrong for the
624
+ // two ways a bio legitimately becomes empty: a bridged actor whose
625
+ // bio was nothing but the bridge's boilerplate (stripped above), and
626
+ // any actor who simply deleted theirs upstream. Coalescing the empty
627
+ // string away made both of those unrepresentable, so the stale text
628
+ // survived every later refresh with nothing in the logs.
629
+ bio: identityBio,
630
+ followersCount,
631
+ followingCount,
632
+ postsCount,
633
+ oxyUserId: fedActor.oxyUserId ?? undefined,
634
+ };
635
+ const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
636
+ if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
637
+ await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
638
+ }
639
+ } catch (resolveErr) {
640
+ this.config.logger.warn(`Failed to resolve Oxy user for ${currentUri}:`, resolveErr);
641
+ }
642
+ }
643
+
644
+ return fedActor;
645
+ } catch (err) {
646
+ this.config.logger.warn(`Failed to fetch remote actor ${currentUri}:`, err);
647
+ return null;
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Run the app's {@link DeriveNetworkIdentity} hook and REFUSE any result the
653
+ * identity bridge could not bind.
654
+ *
655
+ * oxy-api binds a federated username to its domain, so a `federatedUsername`
656
+ * that does not end with `@${instanceDomain}` would be rejected downstream — or
657
+ * worse, mint an identity under a domain it does not name. Validating here means
658
+ * no app can produce that shape, and a hook that gets it wrong degrades to the
659
+ * actor's real protocol acct (the pre-hook behaviour) instead of losing the
660
+ * actor. The refusal is logged: it is a bug in the app's rule, not a normal
661
+ * outcome, and it must not pass silently.
662
+ */
663
+ private resolveNetworkIdentity(
664
+ candidate: NetworkIdentityCandidate,
665
+ ): NetworkIdentity | undefined {
666
+ const derived = this.config.deriveNetworkIdentity?.(candidate);
667
+ if (!derived) return undefined;
668
+
669
+ const domain = derived.instanceDomain.trim().toLowerCase();
670
+ const federatedUsername = derived.federatedUsername.trim().toLowerCase();
671
+ // Everything before the FIRST `@` is the local part; the whole value must then
672
+ // be exactly `<local>@<domain>`. Stated as one equality rather than a list of
673
+ // separate shape checks, so a value that is malformed in a way nobody thought
674
+ // of — a second `@`, a missing one, a different separator — fails by default
675
+ // instead of needing its own clause.
676
+ const atIndex = federatedUsername.indexOf('@');
677
+ const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
678
+ if (
679
+ domain.length === 0
680
+ || localPart.length === 0
681
+ || federatedUsername !== `${localPart}@${domain}`
682
+ ) {
683
+ this.config.logger.warn(
684
+ `[FedSync] refusing network identity for ${candidate.actorUri}: `
685
+ + `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`,
686
+ );
687
+ return undefined;
688
+ }
689
+
690
+ return { federatedUsername, instanceDomain: domain, bio: derived.bio };
691
+ }
692
+
693
+ /**
694
+ * Tombstone a remote actor that returned a definitive 410 Gone. Marks the stored
695
+ * actor suspended (via the store) and, when it links to an Oxy identity, asks
696
+ * oxy-api to archive it so it drops out of search.
697
+ *
698
+ * Best-effort and fail-soft: neither the store write nor the Oxy archive call is
699
+ * allowed to throw out of the caller. Idempotent.
700
+ */
701
+ async tombstoneGoneActor(actorUri: string): Promise<void> {
702
+ try {
703
+ const actor = await this.config.store.tombstoneActor(actorUri);
704
+ if (!actor) {
705
+ this.config.logger.info(`[FedSync] 410 Gone for ${actorUri} — no stored actor row to tombstone`);
706
+ return;
707
+ }
708
+
709
+ this.config.logger.info(`[FedSync] tombstoned gone actor ${actorUri} (suspended)`);
710
+
711
+ if (actor.oxyUserId) {
712
+ const outcome = await this.config.identity.reportActorGone(actor.oxyUserId);
713
+ this.config.logger.info(`[FedSync] actor-gone report for ${actorUri} (oxyUserId ${actor.oxyUserId}) → ${outcome}`);
714
+ }
715
+ } catch (err) {
716
+ this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
717
+ }
718
+ }
719
+
720
+ /** Fetch the totalItems count from an ActivityPub collection URL. */
721
+ private async fetchCollectionCount(url?: string): Promise<number> {
722
+ if (!url) return 0;
723
+ try {
724
+ const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
725
+ if (!res.ok) return 0;
726
+ const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
727
+ return typeof col.totalItems === 'number' ? col.totalItems : 0;
728
+ } catch {
729
+ return 0;
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Get a cached actor or fetch if missing/stale (>24h).
735
+ *
736
+ * Never blocks on remote network I/O when a cached actor already exists: a stale
737
+ * cached actor is returned immediately and a background refresh is enqueued. Only
738
+ * a completely missing actor triggers a blocking fetch.
739
+ */
740
+ async getOrFetchActor(actorUri: string): Promise<TActor | null> {
741
+ // The domain policy governs BOTH branches below, not just the fetching one.
742
+ // `fetchRemoteActor` refuses a blocked host, but the cache hit above it used to
743
+ // return early — so for any instance we had ever stored an actor for (i.e. every
744
+ // instance that has ever reached us), blocking its domain changed nothing. That
745
+ // made the blocklist inert for exactly the hosts it is added for.
746
+ if (this.isBlockedActorUri(actorUri)) {
747
+ this.config.logger.info(`[FedSync] getOrFetchActor refusing own/blocked domain for ${actorUri}`);
748
+ return null;
749
+ }
750
+ const existing = await this.config.store.findActorByUri(actorUri);
751
+ if (existing) {
752
+ const isStale = !existing.lastFetchedAt || Date.now() - existing.lastFetchedAt.getTime() > ACTOR_STALE_MS;
753
+ if (isStale) {
754
+ // Refresh in the background — never block the caller on remote I/O.
755
+ this.refreshActorInBackground(actorUri, existing);
756
+ }
757
+ return existing;
758
+ }
759
+ return this.fetchRemoteActor(actorUri);
760
+ }
761
+
762
+ /**
763
+ * Enqueue a fire-and-forget full-actor refresh. Safe to call on a client request
764
+ * path: it returns synchronously and the fetch runs detached. Guards against
765
+ * refresh storms (in-flight dedup + a recency skip unless the profile is
766
+ * incomplete). The avatar refresh is forced only when the actor already exists.
767
+ */
768
+ refreshActorInBackground(actorUri: string, existing?: TActor): void {
769
+ if (!this.config.federationEnabled) return;
770
+ if (this.inFlightActorRefreshes.has(actorUri)) return;
771
+
772
+ const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
773
+ const lastFetchedMs = existing?.lastFetchedAt?.getTime();
774
+ const refreshedRecently = typeof lastFetchedMs === 'number'
775
+ && Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
776
+
777
+ // Skip if we refreshed recently AND the cached profile is already complete.
778
+ if (refreshedRecently && !missingProfile) return;
779
+
780
+ // Force avatar re-download only when the actor already exists (refresh).
781
+ const forceAvatarRefresh = Boolean(existing);
782
+
783
+ this.inFlightActorRefreshes.add(actorUri);
784
+ void (async () => {
785
+ try {
786
+ await this.fetchRemoteActor(actorUri, forceAvatarRefresh, existing?.acct);
787
+ } catch (err) {
788
+ const message = err instanceof Error ? err.message : String(err);
789
+ this.config.logger.warn(`[FedSync] background actor refresh failed for ${actorUri}: ${message}`);
790
+ } finally {
791
+ this.inFlightActorRefreshes.delete(actorUri);
792
+ }
793
+ })();
794
+ }
795
+
796
+ /**
797
+ * Resolve a remote actor URI to its listable Oxy user id. Returns null when the
798
+ * actor cannot be resolved to an Oxy user — callers must then skip.
799
+ */
800
+ async resolveActorOxyUserId(actorUri: string): Promise<string | null> {
801
+ const actor = await this.getOrFetchActor(actorUri);
802
+ return actor?.oxyUserId ?? null;
803
+ }
804
+
805
+ /**
806
+ * Fetch a public key by keyId (used for HTTP signature verification).
807
+ *
808
+ * Deliberately NOT domain-policy gated on the cached branch: this answers "what
809
+ * key signs for this keyId", a question about authenticity, not about whether we
810
+ * federate with the answer. Suspending an instance is enforced where the activity
811
+ * is dispatched (`createInboundDispatcher`), so a blocked instance's signature is
812
+ * still evaluated honestly and its activity is then dropped as policy, rather
813
+ * than being reported as a forged signature. The uncached branch still refuses,
814
+ * because resolving it would mean network I/O toward a blocked host.
815
+ */
816
+ async fetchPublicKey(keyId: string): Promise<{ publicKeyPem: string; actorUri: string } | null> {
817
+ // keyId is typically the actor URI with #main-key appended
818
+ const actorUri = keyId.replace(/#.*$/, '');
819
+
820
+ // Check local cache first
821
+ const cached = await this.config.store.findActorByPublicKeyId(keyId);
822
+ if (cached?.publicKeyPem) {
823
+ return { publicKeyPem: cached.publicKeyPem, actorUri: cached.uri };
824
+ }
825
+
826
+ // Fetch the actor to get the public key (uses 24h cache)
827
+ const actor = await this.getOrFetchActor(actorUri);
828
+ if (!actor?.publicKeyPem) return null;
829
+
830
+ return { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri };
831
+ }
832
+ }
833
+
834
+ /** Build the remote-actor resolver from an app's storage + identity + transports. */
835
+ export function createActorResolver<TActor extends FederatedActorRecordBase>(
836
+ config: ActorResolverConfig<TActor>,
837
+ ): ActorResolver<TActor> {
838
+ return new ActorResolver(config);
839
+ }