@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.
- package/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/actorObject.js +30 -0
- package/dist/cjs/apContext.js +5 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/node/actorResolver.js +39 -12
- package/dist/cjs/node/actorRouter.js +1 -0
- package/dist/cjs/node/delivery.js +1 -0
- package/dist/cjs/node/inboundDispatch.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/actorObject.js +29 -0
- package/dist/esm/apContext.js +5 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/node/actorResolver.js +39 -12
- package/dist/esm/node/actorRouter.js +1 -0
- package/dist/esm/node/delivery.js +1 -0
- package/dist/esm/node/inboundDispatch.js +51 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/actorObject.d.ts +16 -0
- package/dist/types/apContext.d.ts +4 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/node/actorResolver.d.ts +39 -4
- package/dist/types/node/actorRouter.d.ts +6 -0
- package/dist/types/node/delivery.d.ts +6 -0
- package/dist/types/node/inboundDispatch.d.ts +22 -0
- package/dist/types/node/index.d.ts +2 -2
- package/package.json +3 -3
- package/src/__tests__/actorCollectionCounts.test.ts +172 -0
- package/src/__tests__/actorObject.test.ts +27 -0
- package/src/__tests__/inboundDispatch.test.ts +60 -0
- package/src/actorObject.ts +37 -0
- package/src/apContext.ts +5 -0
- package/src/index.ts +1 -0
- package/src/node/actorResolver.ts +64 -15
- package/src/node/actorRouter.ts +7 -0
- package/src/node/delivery.ts +7 -0
- package/src/node/inboundDispatch.ts +65 -0
- 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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
/**
|
|
721
|
-
|
|
722
|
-
|
|
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)
|
|
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
|
-
|
|
775
|
+
const total = col.totalItems;
|
|
776
|
+
return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
|
|
728
777
|
} catch {
|
|
729
|
-
return
|
|
778
|
+
return undefined;
|
|
730
779
|
}
|
|
731
780
|
}
|
|
732
781
|
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -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);
|
package/src/node/delivery.ts
CHANGED
|
@@ -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,
|