@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
package/dist/esm/actorObject.js
CHANGED
|
@@ -171,6 +171,31 @@ function buildActorImage(config, banner) {
|
|
|
171
171
|
}
|
|
172
172
|
return apImageObject(resolved);
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* The publishable subset of an `alsoKnownAs` input: absolute `https:` URIs,
|
|
176
|
+
* first occurrence wins, input order kept. Exported so every actor builder
|
|
177
|
+
* (Oxy's own included) applies the same rule.
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeAlsoKnownAs(values) {
|
|
180
|
+
if (!values)
|
|
181
|
+
return [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const result = [];
|
|
184
|
+
for (const value of values) {
|
|
185
|
+
if (typeof value !== 'string' || seen.has(value))
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
188
|
+
if (new URL(value).protocol !== 'https:')
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
seen.add(value);
|
|
195
|
+
result.push(value);
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
174
199
|
/**
|
|
175
200
|
* Build the per-instance local-actor builder. Bind it once with an app's domain +
|
|
176
201
|
* media resolver; call the returned function per user.
|
|
@@ -201,6 +226,10 @@ export function createLocalActorBuilder(config) {
|
|
|
201
226
|
publicKeyPem: publicKey.publicKeyPem,
|
|
202
227
|
},
|
|
203
228
|
};
|
|
229
|
+
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
230
|
+
if (aliases.length > 0) {
|
|
231
|
+
actorObject.alsoKnownAs = aliases;
|
|
232
|
+
}
|
|
204
233
|
// `published` (account creation date) is advertised when the API provides it.
|
|
205
234
|
if (createdAt) {
|
|
206
235
|
actorObject.published = new Date(createdAt).toISOString();
|
package/dist/esm/apContext.js
CHANGED
|
@@ -30,7 +30,12 @@ export const AP_CONTEXT = [
|
|
|
30
30
|
// is typed `@id` (an IRI, not a literal); the `misskey`/`fedibird` namespaces
|
|
31
31
|
// and the AS2 `Link` type back the FEP-e232 `Link` quote tag. Without these
|
|
32
32
|
// declarations a strict JSON-LD consumer DROPS the quote fields.
|
|
33
|
+
//
|
|
34
|
+
// `alsoKnownAs` is the account-alias term a Mastodon `Move` verifies. It is
|
|
35
|
+
// `as:alsoKnownAs` typed `@id`, exactly as Mastodon declares it; without the
|
|
36
|
+
// declaration a strict consumer drops the aliases and the move is refused.
|
|
33
37
|
{
|
|
38
|
+
alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
|
|
34
39
|
sensitive: 'as:sensitive',
|
|
35
40
|
toot: 'http://joinmastodon.org/ns#',
|
|
36
41
|
votersCount: 'toot:votersCount',
|
package/dist/esm/index.js
CHANGED
|
@@ -62,4 +62,4 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
62
62
|
* byte-identical across apps, with media resolution injected. The actor `type`
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
|
-
export { createLocalActorBuilder, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
|
|
65
|
+
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, } from './actorObject.js';
|
|
@@ -32,6 +32,12 @@ const AP_CONTENT_TYPE = 'application/activity+json';
|
|
|
32
32
|
/** Maximum decompressed response sizes accepted from untrusted federation hosts. */
|
|
33
33
|
const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
|
|
34
34
|
const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* Collection responses that DEFINITIVELY withhold the count: the owner hid the
|
|
37
|
+
* collection (401/403) or it no longer exists (404/410). Anything else non-2xx
|
|
38
|
+
* is a failed attempt, not an answer.
|
|
39
|
+
*/
|
|
40
|
+
const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
|
|
35
41
|
const ERROR_BODY_MAX_BYTES = 4 * 1024;
|
|
36
42
|
async function readBoundedResponseBody(res, maxBytes) {
|
|
37
43
|
const contentLength = res.headers.get('content-length');
|
|
@@ -389,9 +395,11 @@ export class ActorResolver {
|
|
|
389
395
|
alsoKnownAs,
|
|
390
396
|
networkAcct: networkIdentity?.federatedUsername,
|
|
391
397
|
remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
398
|
+
// Omitted, not written as `undefined`, when this refresh could not tell:
|
|
399
|
+
// an absent key is what tells the store to keep the value it has.
|
|
400
|
+
...(followersCount !== undefined && { followersCount }),
|
|
401
|
+
...(followingCount !== undefined && { followingCount }),
|
|
402
|
+
...(postsCount !== undefined && { postsCount }),
|
|
395
403
|
lastFetchedAt: new Date(),
|
|
396
404
|
};
|
|
397
405
|
const fedActor = await this.config.store.upsertActor(actorId, update);
|
|
@@ -423,9 +431,11 @@ export class ActorResolver {
|
|
|
423
431
|
// string away made both of those unrepresentable, so the stale text
|
|
424
432
|
// survived every later refresh with nothing in the logs.
|
|
425
433
|
bio: identityBio,
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
434
|
+
// The identity bridge takes a number or nothing; an unknown count is
|
|
435
|
+
// sent as nothing rather than as a zero it would store.
|
|
436
|
+
followersCount: followersCount ?? undefined,
|
|
437
|
+
followingCount: followingCount ?? undefined,
|
|
438
|
+
postsCount: postsCount ?? undefined,
|
|
429
439
|
oxyUserId: fedActor.oxyUserId ?? undefined,
|
|
430
440
|
};
|
|
431
441
|
const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
|
|
@@ -503,19 +513,36 @@ export class ActorResolver {
|
|
|
503
513
|
this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
|
|
504
514
|
}
|
|
505
515
|
}
|
|
506
|
-
/**
|
|
516
|
+
/**
|
|
517
|
+
* Read an ActivityPub collection's `totalItems`.
|
|
518
|
+
*
|
|
519
|
+
* Every failure used to come back as `0`, so a follower count the remote HID,
|
|
520
|
+
* or one a timeout kept us from reading, was stored and shown as a real
|
|
521
|
+
* "0 followers" — indistinguishable from an account nobody follows. A failure
|
|
522
|
+
* now says which kind it is (see {@link CollectionCount}):
|
|
523
|
+
*
|
|
524
|
+
* - `null` when the answer is definitive: no collection advertised, 401/403
|
|
525
|
+
* (the owner hid it), 404/410 (it is gone), or a readable collection with no
|
|
526
|
+
* usable `totalItems` (the server does not publish the count).
|
|
527
|
+
* - `undefined` when this attempt simply failed — a thrown fetch (timeout,
|
|
528
|
+
* network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
|
|
529
|
+
* not be read as a JSON object. The next refresh may well succeed, so the
|
|
530
|
+
* caller keeps whatever it last knew rather than forgetting it.
|
|
531
|
+
*/
|
|
507
532
|
async fetchCollectionCount(url) {
|
|
508
533
|
if (!url)
|
|
509
|
-
return
|
|
534
|
+
return null;
|
|
510
535
|
try {
|
|
511
536
|
const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
|
|
512
|
-
if (!res.ok)
|
|
513
|
-
return
|
|
537
|
+
if (!res.ok) {
|
|
538
|
+
return COLLECTION_WITHHELD_STATUSES.has(res.status) ? null : undefined;
|
|
539
|
+
}
|
|
514
540
|
const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
|
|
515
|
-
|
|
541
|
+
const total = col.totalItems;
|
|
542
|
+
return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
|
|
516
543
|
}
|
|
517
544
|
catch {
|
|
518
|
-
return
|
|
545
|
+
return undefined;
|
|
519
546
|
}
|
|
520
547
|
}
|
|
521
548
|
/**
|
|
@@ -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`.
|
|
@@ -38,6 +44,38 @@ export class ActorResolutionPendingError extends Error {
|
|
|
38
44
|
this.actorUri = actorUri;
|
|
39
45
|
}
|
|
40
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The shape check for an inbound `Move`.
|
|
49
|
+
*
|
|
50
|
+
* A Move is only meaningful as an actor moving ITSELF: `actor` and `object` must
|
|
51
|
+
* both be the actor whose HTTP signature was verified, so a relay or a third
|
|
52
|
+
* party cannot move somebody else's followers. `target` must be an absolute
|
|
53
|
+
* https URI, and differ from the old actor.
|
|
54
|
+
*/
|
|
55
|
+
function parseInboundMove(activity, verifiedActorUri) {
|
|
56
|
+
const activityId = typeof activity.id === 'string' ? activity.id : undefined;
|
|
57
|
+
if (!activityId)
|
|
58
|
+
return { ok: false, reason: 'missing id' };
|
|
59
|
+
const actor = objectTargetUri(activity.actor);
|
|
60
|
+
if (actor !== verifiedActorUri)
|
|
61
|
+
return { ok: false, reason: 'actor is not the signer' };
|
|
62
|
+
const object = objectTargetUri(activity.object);
|
|
63
|
+
if (object !== verifiedActorUri)
|
|
64
|
+
return { ok: false, reason: 'object is not the moving actor' };
|
|
65
|
+
const target = objectTargetUri(activity.target);
|
|
66
|
+
if (!target)
|
|
67
|
+
return { ok: false, reason: 'missing target' };
|
|
68
|
+
try {
|
|
69
|
+
if (new URL(target).protocol !== 'https:')
|
|
70
|
+
return { ok: false, reason: 'target is not https' };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { ok: false, reason: 'target is not a URL' };
|
|
74
|
+
}
|
|
75
|
+
if (target === verifiedActorUri)
|
|
76
|
+
return { ok: false, reason: 'target is the moving actor' };
|
|
77
|
+
return { ok: true, move: { activityId, oldActorUri: verifiedActorUri, targetActorUri: target } };
|
|
78
|
+
}
|
|
41
79
|
/**
|
|
42
80
|
* The lowercased host of an actor URI, or null when it is not a parseable absolute
|
|
43
81
|
* URL. Callers treat null as blocked: an origin whose host cannot be determined
|
|
@@ -255,6 +293,19 @@ export function createInboundDispatcher(config) {
|
|
|
255
293
|
case 'Update':
|
|
256
294
|
await config.onContentActivity(activity, verifiedActorUri);
|
|
257
295
|
break;
|
|
296
|
+
case 'Move': {
|
|
297
|
+
const parsed = parseInboundMove(activity, verifiedActorUri);
|
|
298
|
+
if (!parsed.ok) {
|
|
299
|
+
logger.warn(`[Federation] dropping Move from ${verifiedActorUri}: ${parsed.reason}`);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (!config.onMove) {
|
|
303
|
+
logger.debug(`Unhandled Move from ${verifiedActorUri} (no onMove handler)`);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
await config.onMove(parsed.move);
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
258
309
|
default:
|
|
259
310
|
logger.debug(`Unhandled activity type: ${validation.type}`);
|
|
260
311
|
}
|