@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
@@ -45,6 +45,12 @@ const AP_CONTENT_TYPE = 'application/activity+json';
45
45
  /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
46
46
  const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
47
47
  const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
48
+ /**
49
+ * Collection responses that DEFINITIVELY withhold the count: the owner hid the
50
+ * collection (401/403) or it no longer exists (404/410). Anything else non-2xx
51
+ * is a failed attempt, not an answer.
52
+ */
53
+ const COLLECTION_WITHHELD_STATUSES: ReadonlySet<number> = new Set([401, 403, 404, 410]);
48
54
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
49
55
 
50
56
  async function readBoundedResponseBody(res: Response, maxBytes: number): Promise<string> {
@@ -157,12 +163,33 @@ export interface FederatedActorUpsert {
157
163
  */
158
164
  networkAcct?: string;
159
165
  remoteCreatedAt?: Date;
160
- followersCount: number;
161
- followingCount: number;
162
- postsCount: number;
166
+ /**
167
+ * The remote's own totals for its `followers`, `following` and `outbox`
168
+ * collections, read from each collection's `totalItems`. Each is a
169
+ * {@link CollectionCount}, and the three states mean three different writes:
170
+ *
171
+ * - a number — the remote reported it; store it. `0` is a real zero.
172
+ * - `null` — the remote definitively does not disclose it (no collection
173
+ * advertised, a hidden collection answering 401/403, a gone one answering
174
+ * 404/410, or a collection that carries no numeric `totalItems`). Store it
175
+ * as UNKNOWN — never as `0`.
176
+ * - absent (`undefined`) — this refresh could not tell (a timeout, a network
177
+ * error, a 429/5xx, an unreadable body). The store must LEAVE the stored
178
+ * value in place, and a first insert must record it as unknown.
179
+ */
180
+ followersCount?: number | null;
181
+ followingCount?: number | null;
182
+ postsCount?: number | null;
163
183
  lastFetchedAt: Date;
164
184
  }
165
185
 
186
+ /**
187
+ * A remote collection's size as one refresh observed it: the reported
188
+ * `totalItems`, `null` when the remote definitively withholds it, or `undefined`
189
+ * when this attempt could not find out. See {@link FederatedActorUpsert.followersCount}.
190
+ */
191
+ export type CollectionCount = number | null | undefined;
192
+
166
193
  /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
167
194
  export interface FederatedActorStore<TActor extends FederatedActorRecordBase> {
168
195
  /** Look up a cached actor by its protocol URI. */
@@ -591,9 +618,11 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
591
618
  alsoKnownAs,
592
619
  networkAcct: networkIdentity?.federatedUsername,
593
620
  remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
594
- followersCount,
595
- followingCount,
596
- postsCount,
621
+ // Omitted, not written as `undefined`, when this refresh could not tell:
622
+ // an absent key is what tells the store to keep the value it has.
623
+ ...(followersCount !== undefined && { followersCount }),
624
+ ...(followingCount !== undefined && { followingCount }),
625
+ ...(postsCount !== undefined && { postsCount }),
597
626
  lastFetchedAt: new Date(),
598
627
  };
599
628
 
@@ -627,9 +656,11 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
627
656
  // string away made both of those unrepresentable, so the stale text
628
657
  // survived every later refresh with nothing in the logs.
629
658
  bio: identityBio,
630
- followersCount,
631
- followingCount,
632
- postsCount,
659
+ // The identity bridge takes a number or nothing; an unknown count is
660
+ // sent as nothing rather than as a zero it would store.
661
+ followersCount: followersCount ?? undefined,
662
+ followingCount: followingCount ?? undefined,
663
+ postsCount: postsCount ?? undefined,
633
664
  oxyUserId: fedActor.oxyUserId ?? undefined,
634
665
  };
635
666
  const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
@@ -717,16 +748,34 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
717
748
  }
718
749
  }
719
750
 
720
- /** Fetch the totalItems count from an ActivityPub collection URL. */
721
- private async fetchCollectionCount(url?: string): Promise<number> {
722
- if (!url) return 0;
751
+ /**
752
+ * Read an ActivityPub collection's `totalItems`.
753
+ *
754
+ * Every failure used to come back as `0`, so a follower count the remote HID,
755
+ * or one a timeout kept us from reading, was stored and shown as a real
756
+ * "0 followers" — indistinguishable from an account nobody follows. A failure
757
+ * now says which kind it is (see {@link CollectionCount}):
758
+ *
759
+ * - `null` when the answer is definitive: no collection advertised, 401/403
760
+ * (the owner hid it), 404/410 (it is gone), or a readable collection with no
761
+ * usable `totalItems` (the server does not publish the count).
762
+ * - `undefined` when this attempt simply failed — a thrown fetch (timeout,
763
+ * network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
764
+ * not be read as a JSON object. The next refresh may well succeed, so the
765
+ * caller keeps whatever it last knew rather than forgetting it.
766
+ */
767
+ private async fetchCollectionCount(url?: string): Promise<CollectionCount> {
768
+ if (!url) return null;
723
769
  try {
724
770
  const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
725
- if (!res.ok) return 0;
771
+ if (!res.ok) {
772
+ return COLLECTION_WITHHELD_STATUSES.has(res.status) ? null : undefined;
773
+ }
726
774
  const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
727
- return typeof col.totalItems === 'number' ? col.totalItems : 0;
775
+ const total = col.totalItems;
776
+ return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
728
777
  } catch {
729
- return 0;
778
+ return undefined;
730
779
  }
731
780
  }
732
781
 
@@ -41,6 +41,12 @@ export interface ActorRouteUser {
41
41
  createdAt?: string | null;
42
42
  /** Account-graph classification — decides the actor `type`. */
43
43
  kind?: AccountKind | null;
44
+ /**
45
+ * The actor URIs Oxy publishes as this account's aliases
46
+ * (`GET /profiles/username/:username` → `alsoKnownAs`). Emitted on the actor
47
+ * only when non-empty.
48
+ */
49
+ alsoKnownAs?: readonly string[] | null;
44
50
  _count?: { followers?: number; following?: number } | null;
45
51
  }
46
52
 
@@ -388,6 +394,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
388
394
  profileHeaderImage,
389
395
  publicKey,
390
396
  createdAt: user.createdAt,
397
+ alsoKnownAs: user.alsoKnownAs,
391
398
  });
392
399
 
393
400
  res.set('Content-Type', apContentType);
@@ -177,6 +177,12 @@ export interface DeliveryActorProfile {
177
177
  * staleness window).
178
178
  */
179
179
  kind?: AccountKind | null;
180
+ /**
181
+ * The aliases Oxy publishes for the account. Must travel with the `Update`
182
+ * for the same reason `kind` does: the pushed actor and the fetched actor are
183
+ * one document, and a follower's instance learns a new alias from this push.
184
+ */
185
+ alsoKnownAs?: readonly string[] | null;
180
186
  }
181
187
 
182
188
  /** Resolve a local username to its Oxy profile (for the `Update(Person)` rebroadcast). */
@@ -695,6 +701,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
695
701
  profileHeaderImage,
696
702
  publicKey,
697
703
  createdAt: user.createdAt,
704
+ alsoKnownAs: user.alsoKnownAs,
698
705
  });
699
706
 
700
707
  const actor = urls.actor(username);
@@ -12,6 +12,12 @@
12
12
  * post/engagement handlers live. The consent gate + notification side effects are
13
13
  * injected so the engine holds no app knowledge.
14
14
  *
15
+ * `Move` (account migration) is handed to {@link InboundDispatcherConfig.onMove}
16
+ * after a SHAPE check only: the engine proves the activity is the signing actor
17
+ * moving itself, and the app forwards it to Oxy (`POST /federation/move`), which
18
+ * owns the identity decision — the alias check and the re-fetch of the old
19
+ * actor's `movedTo`.
20
+ *
15
21
  * Extracted behaviour-identically from Mention's former `InboxProcessingService`
16
22
  * dispatcher + `handleIncomingFollow` / `handleUndo(Follow)` / `handleAccept` /
17
23
  * `handleReject`.
@@ -159,10 +165,56 @@ export interface InboundDispatcherConfig {
159
165
  * Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
160
166
  */
161
167
  onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
168
+ /**
169
+ * Handle an account `Move` whose shape the engine has verified: signed by
170
+ * `oldActorUri`, which is both its `actor` and its `object`, naming a
171
+ * `targetActorUri`. The app forwards it to Oxy (`POST /federation/move`),
172
+ * which decides. Absent ⇒ a Move is logged and dropped.
173
+ */
174
+ onMove?(move: InboundMove): Promise<void>;
162
175
  /** Diagnostics sink. */
163
176
  logger: InboundDispatcherLogger;
164
177
  }
165
178
 
179
+ /** A shape-verified inbound `Move`. Nothing here is trusted beyond the signature. */
180
+ export interface InboundMove {
181
+ /** The activity `id`, the idempotency key Oxy records. */
182
+ activityId: string;
183
+ /** The account that is moving — the verified signer, its `actor` and its `object`. */
184
+ oldActorUri: string;
185
+ /** Where it says it moved. Oxy checks this names a local account that aliases the old one. */
186
+ targetActorUri: string;
187
+ }
188
+
189
+ /**
190
+ * The shape check for an inbound `Move`.
191
+ *
192
+ * A Move is only meaningful as an actor moving ITSELF: `actor` and `object` must
193
+ * both be the actor whose HTTP signature was verified, so a relay or a third
194
+ * party cannot move somebody else's followers. `target` must be an absolute
195
+ * https URI, and differ from the old actor.
196
+ */
197
+ function parseInboundMove(
198
+ activity: Record<string, unknown>,
199
+ verifiedActorUri: string,
200
+ ): { ok: true; move: InboundMove } | { ok: false; reason: string } {
201
+ const activityId = typeof activity.id === 'string' ? activity.id : undefined;
202
+ if (!activityId) return { ok: false, reason: 'missing id' };
203
+ const actor = objectTargetUri(activity.actor);
204
+ if (actor !== verifiedActorUri) return { ok: false, reason: 'actor is not the signer' };
205
+ const object = objectTargetUri(activity.object);
206
+ if (object !== verifiedActorUri) return { ok: false, reason: 'object is not the moving actor' };
207
+ const target = objectTargetUri(activity.target);
208
+ if (!target) return { ok: false, reason: 'missing target' };
209
+ try {
210
+ if (new URL(target).protocol !== 'https:') return { ok: false, reason: 'target is not https' };
211
+ } catch {
212
+ return { ok: false, reason: 'target is not a URL' };
213
+ }
214
+ if (target === verifiedActorUri) return { ok: false, reason: 'target is the moving actor' };
215
+ return { ok: true, move: { activityId, oldActorUri: verifiedActorUri, targetActorUri: target } };
216
+ }
217
+
166
218
  /** The inbound-activity dispatcher. */
167
219
  export interface InboundDispatcher {
168
220
  /** Process one already-actor-verified inbound activity. */
@@ -411,6 +463,19 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
411
463
  case 'Update':
412
464
  await config.onContentActivity(activity, verifiedActorUri);
413
465
  break;
466
+ case 'Move': {
467
+ const parsed = parseInboundMove(activity, verifiedActorUri);
468
+ if (!parsed.ok) {
469
+ logger.warn(`[Federation] dropping Move from ${verifiedActorUri}: ${parsed.reason}`);
470
+ break;
471
+ }
472
+ if (!config.onMove) {
473
+ logger.debug(`Unhandled Move from ${verifiedActorUri} (no onMove handler)`);
474
+ break;
475
+ }
476
+ await config.onMove(parsed.move);
477
+ break;
478
+ }
414
479
  default:
415
480
  logger.debug(`Unhandled activity type: ${validation.type}`);
416
481
  }
package/src/node/index.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  type ActorResolverLogger,
56
56
  type ActorTextAdapter,
57
57
  type FederatedActorStore,
58
+ type CollectionCount,
58
59
  type FederatedActorRecordBase,
59
60
  type FederatedActorUpsert,
60
61
  type FederatedActorField,
@@ -100,6 +101,7 @@ export {
100
101
  export {
101
102
  createInboundDispatcher,
102
103
  ActorResolutionPendingError,
104
+ type InboundMove,
103
105
  type InboundDispatcher,
104
106
  type InboundDispatcherConfig,
105
107
  type InboundDispatcherLogger,