@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
|
@@ -168,6 +168,16 @@ export interface BuildLocalActorParams {
|
|
|
168
168
|
publicKeyPem: string;
|
|
169
169
|
};
|
|
170
170
|
createdAt?: string | null;
|
|
171
|
+
/**
|
|
172
|
+
* The actor URIs this account is ALSO known as — the ActivityPub
|
|
173
|
+
* `alsoKnownAs` a Mastodon `Move` checks before it lets followers follow the
|
|
174
|
+
* account here. Oxy derives it from the user's live, ownership-proven linked
|
|
175
|
+
* accounts; the builder only publishes it. Omitted from the document when
|
|
176
|
+
* absent or empty (an empty array is not a claim worth making), de-duplicated,
|
|
177
|
+
* and restricted to absolute `https:` URIs, because a receiver dereferences
|
|
178
|
+
* each one.
|
|
179
|
+
*/
|
|
180
|
+
alsoKnownAs?: readonly string[] | null;
|
|
171
181
|
}
|
|
172
182
|
/**
|
|
173
183
|
* Assembles a LOCAL user's AP actor object (WITHOUT the top-level `@context`).
|
|
@@ -175,6 +185,12 @@ export interface BuildLocalActorParams {
|
|
|
175
185
|
* {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}.
|
|
176
186
|
*/
|
|
177
187
|
export type LocalActorBuilder = (params: BuildLocalActorParams) => Record<string, unknown>;
|
|
188
|
+
/**
|
|
189
|
+
* The publishable subset of an `alsoKnownAs` input: absolute `https:` URIs,
|
|
190
|
+
* first occurrence wins, input order kept. Exported so every actor builder
|
|
191
|
+
* (Oxy's own included) applies the same rule.
|
|
192
|
+
*/
|
|
193
|
+
export declare function normalizeAlsoKnownAs(values: readonly string[] | null | undefined): string[];
|
|
178
194
|
/**
|
|
179
195
|
* Build the per-instance local-actor builder. Bind it once with an app's domain +
|
|
180
196
|
* media resolver; call the returned function per user.
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* (FEP-044f / FEP-e232 across Mastodon, Fedibird, Misskey and Pleroma/Akkoma).
|
|
11
11
|
*/
|
|
12
12
|
export declare const AP_CONTEXT: (string | {
|
|
13
|
+
alsoKnownAs: {
|
|
14
|
+
'@id': string;
|
|
15
|
+
'@type': string;
|
|
16
|
+
};
|
|
13
17
|
sensitive: string;
|
|
14
18
|
toot: string;
|
|
15
19
|
votersCount: string;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ 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, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
|
|
65
|
+
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
|
|
66
66
|
/** Supported external networks. */
|
|
67
67
|
export type NetworkId = 'activitypub' | 'atproto';
|
|
68
68
|
/**
|
|
@@ -78,11 +78,31 @@ export interface FederatedActorUpsert {
|
|
|
78
78
|
*/
|
|
79
79
|
networkAcct?: string;
|
|
80
80
|
remoteCreatedAt?: Date;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The remote's own totals for its `followers`, `following` and `outbox`
|
|
83
|
+
* collections, read from each collection's `totalItems`. Each is a
|
|
84
|
+
* {@link CollectionCount}, and the three states mean three different writes:
|
|
85
|
+
*
|
|
86
|
+
* - a number — the remote reported it; store it. `0` is a real zero.
|
|
87
|
+
* - `null` — the remote definitively does not disclose it (no collection
|
|
88
|
+
* advertised, a hidden collection answering 401/403, a gone one answering
|
|
89
|
+
* 404/410, or a collection that carries no numeric `totalItems`). Store it
|
|
90
|
+
* as UNKNOWN — never as `0`.
|
|
91
|
+
* - absent (`undefined`) — this refresh could not tell (a timeout, a network
|
|
92
|
+
* error, a 429/5xx, an unreadable body). The store must LEAVE the stored
|
|
93
|
+
* value in place, and a first insert must record it as unknown.
|
|
94
|
+
*/
|
|
95
|
+
followersCount?: number | null;
|
|
96
|
+
followingCount?: number | null;
|
|
97
|
+
postsCount?: number | null;
|
|
84
98
|
lastFetchedAt: Date;
|
|
85
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* A remote collection's size as one refresh observed it: the reported
|
|
102
|
+
* `totalItems`, `null` when the remote definitively withholds it, or `undefined`
|
|
103
|
+
* when this attempt could not find out. See {@link FederatedActorUpsert.followersCount}.
|
|
104
|
+
*/
|
|
105
|
+
export type CollectionCount = number | null | undefined;
|
|
86
106
|
/** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
|
|
87
107
|
export interface FederatedActorStore<TActor extends FederatedActorRecordBase> {
|
|
88
108
|
/** Look up a cached actor by its protocol URI. */
|
|
@@ -245,7 +265,22 @@ export declare class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
245
265
|
* allowed to throw out of the caller. Idempotent.
|
|
246
266
|
*/
|
|
247
267
|
tombstoneGoneActor(actorUri: string): Promise<void>;
|
|
248
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Read an ActivityPub collection's `totalItems`.
|
|
270
|
+
*
|
|
271
|
+
* Every failure used to come back as `0`, so a follower count the remote HID,
|
|
272
|
+
* or one a timeout kept us from reading, was stored and shown as a real
|
|
273
|
+
* "0 followers" — indistinguishable from an account nobody follows. A failure
|
|
274
|
+
* now says which kind it is (see {@link CollectionCount}):
|
|
275
|
+
*
|
|
276
|
+
* - `null` when the answer is definitive: no collection advertised, 401/403
|
|
277
|
+
* (the owner hid it), 404/410 (it is gone), or a readable collection with no
|
|
278
|
+
* usable `totalItems` (the server does not publish the count).
|
|
279
|
+
* - `undefined` when this attempt simply failed — a thrown fetch (timeout,
|
|
280
|
+
* network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
|
|
281
|
+
* not be read as a JSON object. The next refresh may well succeed, so the
|
|
282
|
+
* caller keeps whatever it last knew rather than forgetting it.
|
|
283
|
+
*/
|
|
249
284
|
private fetchCollectionCount;
|
|
250
285
|
/**
|
|
251
286
|
* Get a cached actor or fetch if missing/stale (>24h).
|
|
@@ -35,6 +35,12 @@ export interface ActorRouteUser {
|
|
|
35
35
|
createdAt?: string | null;
|
|
36
36
|
/** Account-graph classification — decides the actor `type`. */
|
|
37
37
|
kind?: AccountKind | null;
|
|
38
|
+
/**
|
|
39
|
+
* The actor URIs Oxy publishes as this account's aliases
|
|
40
|
+
* (`GET /profiles/username/:username` → `alsoKnownAs`). Emitted on the actor
|
|
41
|
+
* only when non-empty.
|
|
42
|
+
*/
|
|
43
|
+
alsoKnownAs?: readonly string[] | null;
|
|
38
44
|
_count?: {
|
|
39
45
|
followers?: number;
|
|
40
46
|
following?: number;
|
|
@@ -164,6 +164,12 @@ export interface DeliveryActorProfile {
|
|
|
164
164
|
* staleness window).
|
|
165
165
|
*/
|
|
166
166
|
kind?: AccountKind | null;
|
|
167
|
+
/**
|
|
168
|
+
* The aliases Oxy publishes for the account. Must travel with the `Update`
|
|
169
|
+
* for the same reason `kind` does: the pushed actor and the fetched actor are
|
|
170
|
+
* one document, and a follower's instance learns a new alias from this push.
|
|
171
|
+
*/
|
|
172
|
+
alsoKnownAs?: readonly string[] | null;
|
|
167
173
|
}
|
|
168
174
|
/** Resolve a local username to its Oxy profile (for the `Update(Person)` rebroadcast). */
|
|
169
175
|
export interface DeliveryIdentity {
|
|
@@ -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`.
|
|
@@ -144,9 +150,25 @@ export interface InboundDispatcherConfig {
|
|
|
144
150
|
* Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
|
|
145
151
|
*/
|
|
146
152
|
onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
|
|
153
|
+
/**
|
|
154
|
+
* Handle an account `Move` whose shape the engine has verified: signed by
|
|
155
|
+
* `oldActorUri`, which is both its `actor` and its `object`, naming a
|
|
156
|
+
* `targetActorUri`. The app forwards it to Oxy (`POST /federation/move`),
|
|
157
|
+
* which decides. Absent ⇒ a Move is logged and dropped.
|
|
158
|
+
*/
|
|
159
|
+
onMove?(move: InboundMove): Promise<void>;
|
|
147
160
|
/** Diagnostics sink. */
|
|
148
161
|
logger: InboundDispatcherLogger;
|
|
149
162
|
}
|
|
163
|
+
/** A shape-verified inbound `Move`. Nothing here is trusted beyond the signature. */
|
|
164
|
+
export interface InboundMove {
|
|
165
|
+
/** The activity `id`, the idempotency key Oxy records. */
|
|
166
|
+
activityId: string;
|
|
167
|
+
/** The account that is moving — the verified signer, its `actor` and its `object`. */
|
|
168
|
+
oldActorUri: string;
|
|
169
|
+
/** Where it says it moved. Oxy checks this names a local account that aliases the old one. */
|
|
170
|
+
targetActorUri: string;
|
|
171
|
+
}
|
|
150
172
|
/** The inbound-activity dispatcher. */
|
|
151
173
|
export interface InboundDispatcher {
|
|
152
174
|
/** Process one already-actor-verified inbound activity. */
|
|
@@ -28,7 +28,7 @@ export { createIdentityBridge, type IdentityBridge, type IdentityBridgeConfig, t
|
|
|
28
28
|
* 410-Gone tombstone) over a bring-your-own-store adapter, the identity bridge,
|
|
29
29
|
* and injected transports + text normalization.
|
|
30
30
|
*/
|
|
31
|
-
export { createActorResolver, ActorResolver, type ActorResolverConfig, type ActorResolverIdentity, type ActorResolverLogger, type ActorTextAdapter, type FederatedActorStore, type FederatedActorRecordBase, type FederatedActorUpsert, type FederatedActorField, type WebFingerFetch, type WebFingerJrd, } from './actorResolver';
|
|
31
|
+
export { createActorResolver, ActorResolver, type ActorResolverConfig, type ActorResolverIdentity, type ActorResolverLogger, type ActorTextAdapter, type FederatedActorStore, type CollectionCount, type FederatedActorRecordBase, type FederatedActorUpsert, type FederatedActorField, type WebFingerFetch, type WebFingerJrd, } from './actorResolver';
|
|
32
32
|
/**
|
|
33
33
|
* Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
|
|
34
34
|
* Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
|
|
@@ -41,7 +41,7 @@ export { createDeliveryService, type DeliveryService, type DeliveryServiceConfig
|
|
|
41
41
|
* identity + store adapters, and the `onContentActivity` seam every content verb
|
|
42
42
|
* (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
|
|
43
43
|
*/
|
|
44
|
-
export { createInboundDispatcher, ActorResolutionPendingError, type InboundDispatcher, type InboundDispatcherConfig, type InboundDispatcherLogger, type InboundActivityValidation, type InboundLocalUser, type InboundIdentity, type InboundConsent, type InboundActorResolver, type InboundFollowStore, type InboundDelivery, } from './inboundDispatch';
|
|
44
|
+
export { createInboundDispatcher, ActorResolutionPendingError, type InboundMove, type InboundDispatcher, type InboundDispatcherConfig, type InboundDispatcherLogger, type InboundActivityValidation, type InboundLocalUser, type InboundIdentity, type InboundConsent, type InboundActorResolver, type InboundFollowStore, type InboundDelivery, } from './inboundDispatch';
|
|
45
45
|
/** The WebFinger + host-meta discovery router (domain-parameterized, consent-gated). */
|
|
46
46
|
export { createWebfingerRouter, type WebfingerRouterConfig, type WebfingerSharingState, type WebfingerUser, type WebfingerJrd, type WebfingerLogger, } from './webfingerRouter';
|
|
47
47
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"directory": "packages/federation"
|
|
62
62
|
},
|
|
63
63
|
"author": "OxyHQ",
|
|
64
|
-
"license": "
|
|
64
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
65
65
|
"homepage": "https://oxy.so",
|
|
66
66
|
"engines": {
|
|
67
67
|
"node": ">=18.0.0"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
|
-
"build": "
|
|
70
|
+
"build": "node ../core/scripts/build-workspace-deps.mjs @oxy.so/contracts @oxy.so/protocol @oxy.so/core && bun run build:cjs && bun run build:esm && bun run build:types",
|
|
71
71
|
"build:cjs": "tsc -p tsconfig.cjs.json",
|
|
72
72
|
"build:esm": "tsc -p tsconfig.esm.json && node scripts/fix-esm-imports.mjs",
|
|
73
73
|
"build:types": "tsc -p tsconfig.types.json",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { NormalizedExternalActor } from '../index';
|
|
2
|
+
import {
|
|
3
|
+
createActorResolver,
|
|
4
|
+
type ActorResolverConfig,
|
|
5
|
+
type FederatedActorRecordBase,
|
|
6
|
+
type FederatedActorUpsert,
|
|
7
|
+
} from '../node/actorResolver';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A remote collection count that could not be read is UNKNOWN, not zero.
|
|
11
|
+
*
|
|
12
|
+
* `fetchCollectionCount` used to turn every failure — a hidden collection's 403,
|
|
13
|
+
* a timeout, a body with no `totalItems` — into `0`, so the store recorded and
|
|
14
|
+
* the profile showed "0 followers" for accounts with thousands. These pin the
|
|
15
|
+
* three outcomes apart: a reported number (including a real 0), a definitive
|
|
16
|
+
* `null`, and an ABSENT key for a failed attempt, which is what tells the store
|
|
17
|
+
* to keep the value it already has.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface TestActor extends FederatedActorRecordBase {
|
|
21
|
+
uri: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ACTOR = 'https://remote.example/users/bob';
|
|
25
|
+
const FOLLOWERS = 'https://remote.example/users/bob/followers';
|
|
26
|
+
const FOLLOWING = 'https://remote.example/users/bob/following';
|
|
27
|
+
const OUTBOX = 'https://remote.example/users/bob/outbox';
|
|
28
|
+
|
|
29
|
+
type CollectionReply = Response | (() => Promise<Response>);
|
|
30
|
+
|
|
31
|
+
function actorDocument(overrides: Record<string, unknown> = {}): string {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
id: ACTOR,
|
|
34
|
+
type: 'Person',
|
|
35
|
+
inbox: 'https://remote.example/users/bob/inbox',
|
|
36
|
+
preferredUsername: 'bob',
|
|
37
|
+
followers: FOLLOWERS,
|
|
38
|
+
following: FOLLOWING,
|
|
39
|
+
outbox: OUTBOX,
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collection(totalItems: unknown): Response {
|
|
45
|
+
return new Response(JSON.stringify({ type: 'OrderedCollection', totalItems }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function resolveWith(replies: {
|
|
49
|
+
actor?: string;
|
|
50
|
+
followers?: CollectionReply;
|
|
51
|
+
following?: CollectionReply;
|
|
52
|
+
outbox?: CollectionReply;
|
|
53
|
+
}): Promise<{ upserts: FederatedActorUpsert[]; bridged: NormalizedExternalActor[] }> {
|
|
54
|
+
const upserts: FederatedActorUpsert[] = [];
|
|
55
|
+
const bridged: NormalizedExternalActor[] = [];
|
|
56
|
+
const reply = (r: CollectionReply | undefined): Promise<Response> => {
|
|
57
|
+
if (!r) return Promise.resolve(collection(7));
|
|
58
|
+
return typeof r === 'function' ? r() : Promise.resolve(r);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const config: ActorResolverConfig<TestActor> = {
|
|
62
|
+
federationEnabled: true,
|
|
63
|
+
signedFetch: async (url) => {
|
|
64
|
+
if (url === ACTOR) return new Response(replies.actor ?? actorDocument());
|
|
65
|
+
if (url === FOLLOWERS) return reply(replies.followers);
|
|
66
|
+
if (url === FOLLOWING) return reply(replies.following);
|
|
67
|
+
if (url === OUTBOX) return reply(replies.outbox);
|
|
68
|
+
return new Response(null, { status: 404 });
|
|
69
|
+
},
|
|
70
|
+
fetchWebFinger: async () => null,
|
|
71
|
+
isBlockedDomain: () => false,
|
|
72
|
+
normalizeFederatedAcct: (acct) => acct,
|
|
73
|
+
domainFromAcct: (acct) => acct.split('@')[1],
|
|
74
|
+
firstStringUrl: () => undefined,
|
|
75
|
+
store: {
|
|
76
|
+
findActorByUri: async () => null,
|
|
77
|
+
upsertActor: async (uri, update) => {
|
|
78
|
+
upserts.push(update);
|
|
79
|
+
return { _id: 'row-1', uri };
|
|
80
|
+
},
|
|
81
|
+
findActorByPublicKeyId: async () => null,
|
|
82
|
+
setActorOxyUserId: async () => {},
|
|
83
|
+
tombstoneActor: async () => null,
|
|
84
|
+
},
|
|
85
|
+
identity: {
|
|
86
|
+
resolveExternalUser: async (actor) => {
|
|
87
|
+
bridged.push(actor);
|
|
88
|
+
return null;
|
|
89
|
+
},
|
|
90
|
+
reportActorGone: async () => 'archived',
|
|
91
|
+
},
|
|
92
|
+
text: {
|
|
93
|
+
inlineField: (value) => (typeof value === 'string' ? value : ''),
|
|
94
|
+
inlineDisplayName: (raw) => raw,
|
|
95
|
+
sanitizeFieldValue: (html) => html,
|
|
96
|
+
htmlToPlainText: (html) => html,
|
|
97
|
+
},
|
|
98
|
+
logger: { info: () => {}, warn: () => {} },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
await createActorResolver(config).fetchRemoteActor(ACTOR);
|
|
102
|
+
return { upserts, bridged };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('fetchRemoteActor — remote collection counts', () => {
|
|
106
|
+
it('stores reported totals, and a real 0 stays 0', async () => {
|
|
107
|
+
const { upserts } = await resolveWith({
|
|
108
|
+
followers: collection(1234),
|
|
109
|
+
following: collection(0),
|
|
110
|
+
outbox: collection(56),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(upserts).toHaveLength(1);
|
|
114
|
+
expect(upserts[0]).toMatchObject({ followersCount: 1234, followingCount: 0, postsCount: 56 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([401, 403, 404, 410])('records a collection that answers %i as unknown (null), not 0', async (status) => {
|
|
118
|
+
const { upserts } = await resolveWith({
|
|
119
|
+
followers: new Response(null, { status }),
|
|
120
|
+
following: collection(3),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(upserts[0]?.followersCount).toBeNull();
|
|
124
|
+
expect(upserts[0]?.followingCount).toBe(3);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.each([
|
|
128
|
+
['missing', undefined],
|
|
129
|
+
['a string', '1234'],
|
|
130
|
+
['negative', -1],
|
|
131
|
+
['fractional', 1.5],
|
|
132
|
+
])('records a collection whose totalItems is %s as unknown (null)', async (_label, totalItems) => {
|
|
133
|
+
const { upserts } = await resolveWith({ followers: collection(totalItems) });
|
|
134
|
+
|
|
135
|
+
expect(upserts[0]?.followersCount).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('records an actor that advertises no collection as unknown (null)', async () => {
|
|
139
|
+
const { upserts } = await resolveWith({
|
|
140
|
+
actor: actorDocument({ followers: undefined, following: undefined }),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(upserts[0]?.followersCount).toBeNull();
|
|
144
|
+
expect(upserts[0]?.followingCount).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it.each([
|
|
148
|
+
['a timeout', () => Promise.reject(new Error('The operation was aborted due to timeout'))],
|
|
149
|
+
['a 500', () => Promise.resolve(new Response(null, { status: 500 }))],
|
|
150
|
+
['a 429', () => Promise.resolve(new Response(null, { status: 429 }))],
|
|
151
|
+
['malformed JSON', () => Promise.resolve(new Response('{"totalItems": 12'))],
|
|
152
|
+
['an empty body', () => Promise.resolve(new Response(''))],
|
|
153
|
+
] as const)('OMITS a count whose fetch failed with %s, so a known value is kept', async (_label, followers) => {
|
|
154
|
+
const { upserts } = await resolveWith({ followers, following: collection(9) });
|
|
155
|
+
|
|
156
|
+
expect(upserts[0]).not.toHaveProperty('followersCount');
|
|
157
|
+
expect(upserts[0]?.followingCount).toBe(9);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('never hands the identity bridge a zero it did not read', async () => {
|
|
161
|
+
const { bridged } = await resolveWith({
|
|
162
|
+
followers: new Response(null, { status: 403 }),
|
|
163
|
+
following: () => Promise.reject(new Error('timeout')),
|
|
164
|
+
outbox: collection(0),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(bridged).toHaveLength(1);
|
|
168
|
+
expect(bridged[0]?.followersCount).toBeUndefined();
|
|
169
|
+
expect(bridged[0]?.followingCount).toBeUndefined();
|
|
170
|
+
expect(bridged[0]?.postsCount).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -102,6 +102,7 @@ describe('createLocalActorBuilder (golden actor vector)', () => {
|
|
|
102
102
|
toot: 'http://joinmastodon.org/ns#',
|
|
103
103
|
votersCount: 'toot:votersCount',
|
|
104
104
|
quote: { '@id': 'https://w3id.org/fep/044f#quote', '@type': '@id' },
|
|
105
|
+
alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
|
|
105
106
|
});
|
|
106
107
|
});
|
|
107
108
|
|
|
@@ -248,6 +249,32 @@ describe('actor type by Oxy account kind', () => {
|
|
|
248
249
|
});
|
|
249
250
|
});
|
|
250
251
|
|
|
252
|
+
describe('createLocalActorBuilder — alsoKnownAs', () => {
|
|
253
|
+
const alias = 'https://mastodon.social/users/nate';
|
|
254
|
+
|
|
255
|
+
it('emits nothing when there are no aliases, so ordinary actors stay byte-identical', () => {
|
|
256
|
+
for (const alsoKnownAs of [undefined, null, []]) {
|
|
257
|
+
const actor = buildActor({ ...PARAMS, alsoKnownAs });
|
|
258
|
+
expect(actor).not.toHaveProperty('alsoKnownAs');
|
|
259
|
+
expect(JSON.stringify(actor)).toBe(JSON.stringify(EXPECTED_ACTOR));
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('emits the aliases after publicKey, de-duplicated and https-only', () => {
|
|
264
|
+
const actor = buildActor({
|
|
265
|
+
...PARAMS,
|
|
266
|
+
alsoKnownAs: [alias, 'http://insecure.example/users/nate', 'not a url', alias, 'https://pleroma.example/users/n'],
|
|
267
|
+
});
|
|
268
|
+
expect(actor.alsoKnownAs).toEqual([alias, 'https://pleroma.example/users/n']);
|
|
269
|
+
const keys = Object.keys(actor);
|
|
270
|
+
expect(keys.indexOf('alsoKnownAs')).toBe(keys.indexOf('publicKey') + 1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('emits nothing when every alias is unpublishable', () => {
|
|
274
|
+
expect(buildActor({ ...PARAMS, alsoKnownAs: ['http://x.example/u'] })).not.toHaveProperty('alsoKnownAs');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
251
278
|
describe('createUrlBuilders', () => {
|
|
252
279
|
it('scopes actor() to actorDomain and the rest to domain', () => {
|
|
253
280
|
const urls = createUrlBuilders('mention.earth', 'actors.mention.earth');
|
|
@@ -29,7 +29,9 @@ function makeRig(overrides: {
|
|
|
29
29
|
actorOxyUserIdForUndo?: string | null;
|
|
30
30
|
validate?: (activity: Record<string, unknown>) => InboundActivityValidation;
|
|
31
31
|
blockedHosts?: string[];
|
|
32
|
+
withMoveHandler?: boolean;
|
|
32
33
|
} = {}) {
|
|
34
|
+
const moves: Array<{ activityId: string; oldActorUri: string; targetActorUri: string }> = [];
|
|
33
35
|
const bridgeFollowCalls: Array<[string, string]> = [];
|
|
34
36
|
const bridgeUnfollowCalls: Array<[string, string]> = [];
|
|
35
37
|
const acceptsSent: Array<{ localOxyUserId: string; localUsername: string; followActivityId: string; remoteActorUri: string }> = [];
|
|
@@ -106,11 +108,19 @@ function makeRig(overrides: {
|
|
|
106
108
|
onContentActivity: async (activity, verifiedActorUri) => {
|
|
107
109
|
contentActivities.push({ type: activity.type, verifiedActorUri });
|
|
108
110
|
},
|
|
111
|
+
...(overrides.withMoveHandler === false
|
|
112
|
+
? {}
|
|
113
|
+
: {
|
|
114
|
+
onMove: async (move: { activityId: string; oldActorUri: string; targetActorUri: string }) => {
|
|
115
|
+
moves.push(move);
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
109
118
|
logger: { debug: () => {}, info: () => {}, warn: () => {} },
|
|
110
119
|
};
|
|
111
120
|
|
|
112
121
|
return {
|
|
113
122
|
dispatcher: createInboundDispatcher(config),
|
|
123
|
+
moves,
|
|
114
124
|
validatedActivities,
|
|
115
125
|
bridgeFollowCalls,
|
|
116
126
|
bridgeUnfollowCalls,
|
|
@@ -249,6 +259,56 @@ describe('inbound Accept / Reject', () => {
|
|
|
249
259
|
});
|
|
250
260
|
});
|
|
251
261
|
|
|
262
|
+
describe('Move → onMove', () => {
|
|
263
|
+
const TARGET = 'https://mention.earth/ap/users/bob';
|
|
264
|
+
const move = (extra: Record<string, unknown> = {}) => ({
|
|
265
|
+
id: `${REMOTE_ACTOR}#moves/1`,
|
|
266
|
+
type: 'Move',
|
|
267
|
+
actor: REMOTE_ACTOR,
|
|
268
|
+
object: REMOTE_ACTOR,
|
|
269
|
+
target: TARGET,
|
|
270
|
+
...extra,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('hands a well-formed self-Move to the app', async () => {
|
|
274
|
+
const rig = makeRig();
|
|
275
|
+
await rig.dispatcher.processInboxActivity(move(), REMOTE_ACTOR);
|
|
276
|
+
expect(rig.moves).toEqual([{ activityId: `${REMOTE_ACTOR}#moves/1`, oldActorUri: REMOTE_ACTOR, targetActorUri: TARGET }]);
|
|
277
|
+
expect(rig.contentActivities).toHaveLength(0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('accepts embedded object/target references', async () => {
|
|
281
|
+
const rig = makeRig();
|
|
282
|
+
await rig.dispatcher.processInboxActivity(move({ object: { id: REMOTE_ACTOR }, target: { id: TARGET } }), REMOTE_ACTOR);
|
|
283
|
+
expect(rig.moves).toHaveLength(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it.each([
|
|
287
|
+
['a Move of someone else', { object: 'https://remote.example/users/carol' }],
|
|
288
|
+
['an actor that is not the signer', { actor: 'https://remote.example/users/carol' }],
|
|
289
|
+
['no target', { target: undefined }],
|
|
290
|
+
['a non-https target', { target: 'http://mention.earth/ap/users/bob' }],
|
|
291
|
+
['a target equal to the mover', { target: REMOTE_ACTOR }],
|
|
292
|
+
['no activity id', { id: undefined }],
|
|
293
|
+
])('drops %s', async (_label, extra) => {
|
|
294
|
+
const rig = makeRig();
|
|
295
|
+
await rig.dispatcher.processInboxActivity(move(extra), REMOTE_ACTOR);
|
|
296
|
+
expect(rig.moves).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('drops a Move signed by a different actor than it names', async () => {
|
|
300
|
+
const rig = makeRig();
|
|
301
|
+
await rig.dispatcher.processInboxActivity(move(), 'https://relay.example/actor');
|
|
302
|
+
expect(rig.moves).toHaveLength(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('drops a Move when the app registered no handler', async () => {
|
|
306
|
+
const rig = makeRig({ withMoveHandler: false });
|
|
307
|
+
await expect(rig.dispatcher.processInboxActivity(move(), REMOTE_ACTOR)).resolves.toBeUndefined();
|
|
308
|
+
expect(rig.contentActivities).toHaveLength(0);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
252
312
|
describe('content verbs → onContentActivity', () => {
|
|
253
313
|
it.each(['Create', 'Announce', 'Like', 'Delete', 'Update'])('routes %s to the app', async (type) => {
|
|
254
314
|
const rig = makeRig();
|
package/src/actorObject.ts
CHANGED
|
@@ -222,6 +222,16 @@ export interface BuildLocalActorParams {
|
|
|
222
222
|
profileHeaderImage?: string | null;
|
|
223
223
|
publicKey: { keyId: string; publicKeyPem: string };
|
|
224
224
|
createdAt?: string | null;
|
|
225
|
+
/**
|
|
226
|
+
* The actor URIs this account is ALSO known as — the ActivityPub
|
|
227
|
+
* `alsoKnownAs` a Mastodon `Move` checks before it lets followers follow the
|
|
228
|
+
* account here. Oxy derives it from the user's live, ownership-proven linked
|
|
229
|
+
* accounts; the builder only publishes it. Omitted from the document when
|
|
230
|
+
* absent or empty (an empty array is not a claim worth making), de-duplicated,
|
|
231
|
+
* and restricted to absolute `https:` URIs, because a receiver dereferences
|
|
232
|
+
* each one.
|
|
233
|
+
*/
|
|
234
|
+
alsoKnownAs?: readonly string[] | null;
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
/**
|
|
@@ -271,6 +281,28 @@ function buildActorImage(
|
|
|
271
281
|
return apImageObject(resolved);
|
|
272
282
|
}
|
|
273
283
|
|
|
284
|
+
/**
|
|
285
|
+
* The publishable subset of an `alsoKnownAs` input: absolute `https:` URIs,
|
|
286
|
+
* first occurrence wins, input order kept. Exported so every actor builder
|
|
287
|
+
* (Oxy's own included) applies the same rule.
|
|
288
|
+
*/
|
|
289
|
+
export function normalizeAlsoKnownAs(values: readonly string[] | null | undefined): string[] {
|
|
290
|
+
if (!values) return [];
|
|
291
|
+
const seen = new Set<string>();
|
|
292
|
+
const result: string[] = [];
|
|
293
|
+
for (const value of values) {
|
|
294
|
+
if (typeof value !== 'string' || seen.has(value)) continue;
|
|
295
|
+
try {
|
|
296
|
+
if (new URL(value).protocol !== 'https:') continue;
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
seen.add(value);
|
|
301
|
+
result.push(value);
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
274
306
|
/**
|
|
275
307
|
* Build the per-instance local-actor builder. Bind it once with an app's domain +
|
|
276
308
|
* media resolver; call the returned function per user.
|
|
@@ -303,6 +335,11 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
|
|
|
303
335
|
},
|
|
304
336
|
};
|
|
305
337
|
|
|
338
|
+
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
339
|
+
if (aliases.length > 0) {
|
|
340
|
+
actorObject.alsoKnownAs = aliases;
|
|
341
|
+
}
|
|
342
|
+
|
|
306
343
|
// `published` (account creation date) is advertised when the API provides it.
|
|
307
344
|
if (createdAt) {
|
|
308
345
|
actorObject.published = new Date(createdAt).toISOString();
|
package/src/apContext.ts
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',
|