@oxy.so/federation 1.0.1 → 2.1.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 (39) hide show
  1. package/LICENSE +675 -201
  2. package/NOTICE +7 -2
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/actorObject.js +30 -0
  5. package/dist/cjs/apContext.js +5 -0
  6. package/dist/cjs/index.js +2 -1
  7. package/dist/cjs/node/actorResolver.js +39 -12
  8. package/dist/cjs/node/actorRouter.js +1 -0
  9. package/dist/cjs/node/delivery.js +1 -0
  10. package/dist/cjs/node/inboundDispatch.js +51 -0
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/actorObject.js +29 -0
  13. package/dist/esm/apContext.js +5 -0
  14. package/dist/esm/index.js +1 -1
  15. package/dist/esm/node/actorResolver.js +39 -12
  16. package/dist/esm/node/actorRouter.js +1 -0
  17. package/dist/esm/node/delivery.js +1 -0
  18. package/dist/esm/node/inboundDispatch.js +51 -0
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/actorObject.d.ts +16 -0
  21. package/dist/types/apContext.d.ts +4 -0
  22. package/dist/types/index.d.ts +1 -1
  23. package/dist/types/node/actorResolver.d.ts +39 -4
  24. package/dist/types/node/actorRouter.d.ts +6 -0
  25. package/dist/types/node/delivery.d.ts +6 -0
  26. package/dist/types/node/inboundDispatch.d.ts +22 -0
  27. package/dist/types/node/index.d.ts +2 -2
  28. package/package.json +3 -3
  29. package/src/__tests__/actorCollectionCounts.test.ts +172 -0
  30. package/src/__tests__/actorObject.test.ts +27 -0
  31. package/src/__tests__/inboundDispatch.test.ts +60 -0
  32. package/src/actorObject.ts +37 -0
  33. package/src/apContext.ts +5 -0
  34. package/src/index.ts +1 -0
  35. package/src/node/actorResolver.ts +64 -15
  36. package/src/node/actorRouter.ts +7 -0
  37. package/src/node/delivery.ts +7 -0
  38. package/src/node/inboundDispatch.ts +65 -0
  39. package/src/node/index.ts +2 -0
@@ -22,6 +22,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = void 0;
23
23
  exports.isApActorType = isApActorType;
24
24
  exports.localActorTypeForAccountKind = localActorTypeForAccountKind;
25
+ exports.normalizeAlsoKnownAs = normalizeAlsoKnownAs;
25
26
  exports.createLocalActorBuilder = createLocalActorBuilder;
26
27
  const contracts_1 = require("@oxy.so/contracts");
27
28
  /**
@@ -177,6 +178,31 @@ function buildActorImage(config, banner) {
177
178
  }
178
179
  return apImageObject(resolved);
179
180
  }
181
+ /**
182
+ * The publishable subset of an `alsoKnownAs` input: absolute `https:` URIs,
183
+ * first occurrence wins, input order kept. Exported so every actor builder
184
+ * (Oxy's own included) applies the same rule.
185
+ */
186
+ function normalizeAlsoKnownAs(values) {
187
+ if (!values)
188
+ return [];
189
+ const seen = new Set();
190
+ const result = [];
191
+ for (const value of values) {
192
+ if (typeof value !== 'string' || seen.has(value))
193
+ continue;
194
+ try {
195
+ if (new URL(value).protocol !== 'https:')
196
+ continue;
197
+ }
198
+ catch {
199
+ continue;
200
+ }
201
+ seen.add(value);
202
+ result.push(value);
203
+ }
204
+ return result;
205
+ }
180
206
  /**
181
207
  * Build the per-instance local-actor builder. Bind it once with an app's domain +
182
208
  * media resolver; call the returned function per user.
@@ -207,6 +233,10 @@ function createLocalActorBuilder(config) {
207
233
  publicKeyPem: publicKey.publicKeyPem,
208
234
  },
209
235
  };
236
+ const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
237
+ if (aliases.length > 0) {
238
+ actorObject.alsoKnownAs = aliases;
239
+ }
210
240
  // `published` (account creation date) is advertised when the API provides it.
211
241
  if (createdAt) {
212
242
  actorObject.published = new Date(createdAt).toISOString();
@@ -33,7 +33,12 @@ exports.AP_CONTEXT = [
33
33
  // is typed `@id` (an IRI, not a literal); the `misskey`/`fedibird` namespaces
34
34
  // and the AS2 `Link` type back the FEP-e232 `Link` quote tag. Without these
35
35
  // declarations a strict JSON-LD consumer DROPS the quote fields.
36
+ //
37
+ // `alsoKnownAs` is the account-alias term a Mastodon `Move` verifies. It is
38
+ // `as:alsoKnownAs` typed `@id`, exactly as Mastodon declares it; without the
39
+ // declaration a strict consumer drops the aliases and the move is refused.
36
40
  {
41
+ alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
37
42
  sensitive: 'as:sensitive',
38
43
  toot: 'http://joinmastodon.org/ns#',
39
44
  votersCount: 'toot:votersCount',
package/dist/cjs/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * knowledge of any app's post shape.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.localActorTypeForAccountKind = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
25
+ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
26
26
  /**
27
27
  * HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
28
28
  * ActivityPub federation shares. Private-key custody is injected; the key never
@@ -93,6 +93,7 @@ Object.defineProperty(exports, "createDomainPolicy", { enumerable: true, get: fu
93
93
  */
94
94
  var actorObject_1 = require("./actorObject");
95
95
  Object.defineProperty(exports, "createLocalActorBuilder", { enumerable: true, get: function () { return actorObject_1.createLocalActorBuilder; } });
96
+ Object.defineProperty(exports, "normalizeAlsoKnownAs", { enumerable: true, get: function () { return actorObject_1.normalizeAlsoKnownAs; } });
96
97
  Object.defineProperty(exports, "localActorTypeForAccountKind", { enumerable: true, get: function () { return actorObject_1.localActorTypeForAccountKind; } });
97
98
  Object.defineProperty(exports, "isApActorType", { enumerable: true, get: function () { return actorObject_1.isApActorType; } });
98
99
  Object.defineProperty(exports, "AP_ACTOR_TYPES", { enumerable: true, get: function () { return actorObject_1.AP_ACTOR_TYPES; } });
@@ -36,6 +36,12 @@ const AP_CONTENT_TYPE = 'application/activity+json';
36
36
  /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
37
37
  const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
38
38
  const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
39
+ /**
40
+ * Collection responses that DEFINITIVELY withhold the count: the owner hid the
41
+ * collection (401/403) or it no longer exists (404/410). Anything else non-2xx
42
+ * is a failed attempt, not an answer.
43
+ */
44
+ const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
39
45
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
40
46
  async function readBoundedResponseBody(res, maxBytes) {
41
47
  const contentLength = res.headers.get('content-length');
@@ -393,9 +399,11 @@ class ActorResolver {
393
399
  alsoKnownAs,
394
400
  networkAcct: networkIdentity?.federatedUsername,
395
401
  remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
396
- followersCount,
397
- followingCount,
398
- postsCount,
402
+ // Omitted, not written as `undefined`, when this refresh could not tell:
403
+ // an absent key is what tells the store to keep the value it has.
404
+ ...(followersCount !== undefined && { followersCount }),
405
+ ...(followingCount !== undefined && { followingCount }),
406
+ ...(postsCount !== undefined && { postsCount }),
399
407
  lastFetchedAt: new Date(),
400
408
  };
401
409
  const fedActor = await this.config.store.upsertActor(actorId, update);
@@ -427,9 +435,11 @@ class ActorResolver {
427
435
  // string away made both of those unrepresentable, so the stale text
428
436
  // survived every later refresh with nothing in the logs.
429
437
  bio: identityBio,
430
- followersCount,
431
- followingCount,
432
- postsCount,
438
+ // The identity bridge takes a number or nothing; an unknown count is
439
+ // sent as nothing rather than as a zero it would store.
440
+ followersCount: followersCount ?? undefined,
441
+ followingCount: followingCount ?? undefined,
442
+ postsCount: postsCount ?? undefined,
433
443
  oxyUserId: fedActor.oxyUserId ?? undefined,
434
444
  };
435
445
  const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
@@ -507,19 +517,36 @@ class ActorResolver {
507
517
  this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
508
518
  }
509
519
  }
510
- /** Fetch the totalItems count from an ActivityPub collection URL. */
520
+ /**
521
+ * Read an ActivityPub collection's `totalItems`.
522
+ *
523
+ * Every failure used to come back as `0`, so a follower count the remote HID,
524
+ * or one a timeout kept us from reading, was stored and shown as a real
525
+ * "0 followers" — indistinguishable from an account nobody follows. A failure
526
+ * now says which kind it is (see {@link CollectionCount}):
527
+ *
528
+ * - `null` when the answer is definitive: no collection advertised, 401/403
529
+ * (the owner hid it), 404/410 (it is gone), or a readable collection with no
530
+ * usable `totalItems` (the server does not publish the count).
531
+ * - `undefined` when this attempt simply failed — a thrown fetch (timeout,
532
+ * network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
533
+ * not be read as a JSON object. The next refresh may well succeed, so the
534
+ * caller keeps whatever it last knew rather than forgetting it.
535
+ */
511
536
  async fetchCollectionCount(url) {
512
537
  if (!url)
513
- return 0;
538
+ return null;
514
539
  try {
515
540
  const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
516
- if (!res.ok)
517
- return 0;
541
+ if (!res.ok) {
542
+ return COLLECTION_WITHHELD_STATUSES.has(res.status) ? null : undefined;
543
+ }
518
544
  const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
519
- return typeof col.totalItems === 'number' ? col.totalItems : 0;
545
+ const total = col.totalItems;
546
+ return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
520
547
  }
521
548
  catch {
522
- return 0;
549
+ return undefined;
523
550
  }
524
551
  }
525
552
  /**
@@ -266,6 +266,7 @@ function createActorRouter(config) {
266
266
  profileHeaderImage,
267
267
  publicKey,
268
268
  createdAt: user.createdAt,
269
+ alsoKnownAs: user.alsoKnownAs,
269
270
  });
270
271
  res.set('Content-Type', apContentType);
271
272
  res.set('Cache-Control', 'max-age=1800');
@@ -383,6 +383,7 @@ function createDeliveryService(config) {
383
383
  profileHeaderImage,
384
384
  publicKey,
385
385
  createdAt: user.createdAt,
386
+ alsoKnownAs: user.alsoKnownAs,
386
387
  });
387
388
  const actor = urls.actor(username);
388
389
  const now = new Date();
@@ -13,6 +13,12 @@
13
13
  * post/engagement handlers live. The consent gate + notification side effects are
14
14
  * injected so the engine holds no app knowledge.
15
15
  *
16
+ * `Move` (account migration) is handed to {@link InboundDispatcherConfig.onMove}
17
+ * after a SHAPE check only: the engine proves the activity is the signing actor
18
+ * moving itself, and the app forwards it to Oxy (`POST /federation/move`), which
19
+ * owns the identity decision — the alias check and the re-fetch of the old
20
+ * actor's `movedTo`.
21
+ *
16
22
  * Extracted behaviour-identically from Mention's former `InboxProcessingService`
17
23
  * dispatcher + `handleIncomingFollow` / `handleUndo(Follow)` / `handleAccept` /
18
24
  * `handleReject`.
@@ -43,6 +49,38 @@ class ActorResolutionPendingError extends Error {
43
49
  }
44
50
  }
45
51
  exports.ActorResolutionPendingError = ActorResolutionPendingError;
52
+ /**
53
+ * The shape check for an inbound `Move`.
54
+ *
55
+ * A Move is only meaningful as an actor moving ITSELF: `actor` and `object` must
56
+ * both be the actor whose HTTP signature was verified, so a relay or a third
57
+ * party cannot move somebody else's followers. `target` must be an absolute
58
+ * https URI, and differ from the old actor.
59
+ */
60
+ function parseInboundMove(activity, verifiedActorUri) {
61
+ const activityId = typeof activity.id === 'string' ? activity.id : undefined;
62
+ if (!activityId)
63
+ return { ok: false, reason: 'missing id' };
64
+ const actor = objectTargetUri(activity.actor);
65
+ if (actor !== verifiedActorUri)
66
+ return { ok: false, reason: 'actor is not the signer' };
67
+ const object = objectTargetUri(activity.object);
68
+ if (object !== verifiedActorUri)
69
+ return { ok: false, reason: 'object is not the moving actor' };
70
+ const target = objectTargetUri(activity.target);
71
+ if (!target)
72
+ return { ok: false, reason: 'missing target' };
73
+ try {
74
+ if (new URL(target).protocol !== 'https:')
75
+ return { ok: false, reason: 'target is not https' };
76
+ }
77
+ catch {
78
+ return { ok: false, reason: 'target is not a URL' };
79
+ }
80
+ if (target === verifiedActorUri)
81
+ return { ok: false, reason: 'target is the moving actor' };
82
+ return { ok: true, move: { activityId, oldActorUri: verifiedActorUri, targetActorUri: target } };
83
+ }
46
84
  /**
47
85
  * The lowercased host of an actor URI, or null when it is not a parseable absolute
48
86
  * URL. Callers treat null as blocked: an origin whose host cannot be determined
@@ -260,6 +298,19 @@ function createInboundDispatcher(config) {
260
298
  case 'Update':
261
299
  await config.onContentActivity(activity, verifiedActorUri);
262
300
  break;
301
+ case 'Move': {
302
+ const parsed = parseInboundMove(activity, verifiedActorUri);
303
+ if (!parsed.ok) {
304
+ logger.warn(`[Federation] dropping Move from ${verifiedActorUri}: ${parsed.reason}`);
305
+ break;
306
+ }
307
+ if (!config.onMove) {
308
+ logger.debug(`Unhandled Move from ${verifiedActorUri} (no onMove handler)`);
309
+ break;
310
+ }
311
+ await config.onMove(parsed.move);
312
+ break;
313
+ }
263
314
  default:
264
315
  logger.debug(`Unhandled activity type: ${validation.type}`);
265
316
  }