@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
@@ -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;
@@ -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
- followersCount: number;
82
- followingCount: number;
83
- postsCount: number;
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
- /** Fetch the totalItems count from an ActivityPub collection URL. */
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.1",
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": "Apache-2.0",
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": "bun run --filter @oxy.so/contracts build && bun run --filter @oxy.so/protocol build && bun run --filter @oxy.so/core build && bun run build:cjs && bun run build:esm && bun run build:types",
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();
@@ -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',
package/src/index.ts CHANGED
@@ -112,6 +112,7 @@ export {
112
112
  */
113
113
  export {
114
114
  createLocalActorBuilder,
115
+ normalizeAlsoKnownAs,
115
116
  localActorTypeForAccountKind,
116
117
  isApActorType,
117
118
  AP_ACTOR_TYPES,