@oxy.so/federation 2.0.0 → 2.1.1

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.
@@ -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
  /**
@@ -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. */
@@ -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": "2.0.0",
3
+ "version": "2.1.1",
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",
@@ -93,7 +93,7 @@
93
93
  }
94
94
  },
95
95
  "dependencies": {
96
- "@oxy.so/contracts": "^1.0.0",
96
+ "@oxy.so/contracts": "^1.0.0 || ^2.0.0",
97
97
  "@oxy.so/core": "^1.0.0"
98
98
  },
99
99
  "peerDependencies": {
@@ -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,
@@ -41,6 +41,12 @@ export interface ActorRouteUser {
41
41
  createdAt?: string | null;
42
42
  /** Account-graph classification — decides the actor `type`. */
43
43
  kind?: AccountKind | null;
44
+ /**
45
+ * The actor URIs Oxy publishes as this account's aliases
46
+ * (`GET /profiles/username/:username` → `alsoKnownAs`). Emitted on the actor
47
+ * only when non-empty.
48
+ */
49
+ alsoKnownAs?: readonly string[] | null;
44
50
  _count?: { followers?: number; following?: number } | null;
45
51
  }
46
52
 
@@ -388,6 +394,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
388
394
  profileHeaderImage,
389
395
  publicKey,
390
396
  createdAt: user.createdAt,
397
+ alsoKnownAs: user.alsoKnownAs,
391
398
  });
392
399
 
393
400
  res.set('Content-Type', apContentType);
@@ -177,6 +177,12 @@ export interface DeliveryActorProfile {
177
177
  * staleness window).
178
178
  */
179
179
  kind?: AccountKind | null;
180
+ /**
181
+ * The aliases Oxy publishes for the account. Must travel with the `Update`
182
+ * for the same reason `kind` does: the pushed actor and the fetched actor are
183
+ * one document, and a follower's instance learns a new alias from this push.
184
+ */
185
+ alsoKnownAs?: readonly string[] | null;
180
186
  }
181
187
 
182
188
  /** Resolve a local username to its Oxy profile (for the `Update(Person)` rebroadcast). */
@@ -695,6 +701,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
695
701
  profileHeaderImage,
696
702
  publicKey,
697
703
  createdAt: user.createdAt,
704
+ alsoKnownAs: user.alsoKnownAs,
698
705
  });
699
706
 
700
707
  const actor = urls.actor(username);
@@ -12,6 +12,12 @@
12
12
  * post/engagement handlers live. The consent gate + notification side effects are
13
13
  * injected so the engine holds no app knowledge.
14
14
  *
15
+ * `Move` (account migration) is handed to {@link InboundDispatcherConfig.onMove}
16
+ * after a SHAPE check only: the engine proves the activity is the signing actor
17
+ * moving itself, and the app forwards it to Oxy (`POST /federation/move`), which
18
+ * owns the identity decision — the alias check and the re-fetch of the old
19
+ * actor's `movedTo`.
20
+ *
15
21
  * Extracted behaviour-identically from Mention's former `InboxProcessingService`
16
22
  * dispatcher + `handleIncomingFollow` / `handleUndo(Follow)` / `handleAccept` /
17
23
  * `handleReject`.
@@ -159,10 +165,56 @@ export interface InboundDispatcherConfig {
159
165
  * Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
160
166
  */
161
167
  onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
168
+ /**
169
+ * Handle an account `Move` whose shape the engine has verified: signed by
170
+ * `oldActorUri`, which is both its `actor` and its `object`, naming a
171
+ * `targetActorUri`. The app forwards it to Oxy (`POST /federation/move`),
172
+ * which decides. Absent ⇒ a Move is logged and dropped.
173
+ */
174
+ onMove?(move: InboundMove): Promise<void>;
162
175
  /** Diagnostics sink. */
163
176
  logger: InboundDispatcherLogger;
164
177
  }
165
178
 
179
+ /** A shape-verified inbound `Move`. Nothing here is trusted beyond the signature. */
180
+ export interface InboundMove {
181
+ /** The activity `id`, the idempotency key Oxy records. */
182
+ activityId: string;
183
+ /** The account that is moving — the verified signer, its `actor` and its `object`. */
184
+ oldActorUri: string;
185
+ /** Where it says it moved. Oxy checks this names a local account that aliases the old one. */
186
+ targetActorUri: string;
187
+ }
188
+
189
+ /**
190
+ * The shape check for an inbound `Move`.
191
+ *
192
+ * A Move is only meaningful as an actor moving ITSELF: `actor` and `object` must
193
+ * both be the actor whose HTTP signature was verified, so a relay or a third
194
+ * party cannot move somebody else's followers. `target` must be an absolute
195
+ * https URI, and differ from the old actor.
196
+ */
197
+ function parseInboundMove(
198
+ activity: Record<string, unknown>,
199
+ verifiedActorUri: string,
200
+ ): { ok: true; move: InboundMove } | { ok: false; reason: string } {
201
+ const activityId = typeof activity.id === 'string' ? activity.id : undefined;
202
+ if (!activityId) return { ok: false, reason: 'missing id' };
203
+ const actor = objectTargetUri(activity.actor);
204
+ if (actor !== verifiedActorUri) return { ok: false, reason: 'actor is not the signer' };
205
+ const object = objectTargetUri(activity.object);
206
+ if (object !== verifiedActorUri) return { ok: false, reason: 'object is not the moving actor' };
207
+ const target = objectTargetUri(activity.target);
208
+ if (!target) return { ok: false, reason: 'missing target' };
209
+ try {
210
+ if (new URL(target).protocol !== 'https:') return { ok: false, reason: 'target is not https' };
211
+ } catch {
212
+ return { ok: false, reason: 'target is not a URL' };
213
+ }
214
+ if (target === verifiedActorUri) return { ok: false, reason: 'target is the moving actor' };
215
+ return { ok: true, move: { activityId, oldActorUri: verifiedActorUri, targetActorUri: target } };
216
+ }
217
+
166
218
  /** The inbound-activity dispatcher. */
167
219
  export interface InboundDispatcher {
168
220
  /** Process one already-actor-verified inbound activity. */
@@ -411,6 +463,19 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
411
463
  case 'Update':
412
464
  await config.onContentActivity(activity, verifiedActorUri);
413
465
  break;
466
+ case 'Move': {
467
+ const parsed = parseInboundMove(activity, verifiedActorUri);
468
+ if (!parsed.ok) {
469
+ logger.warn(`[Federation] dropping Move from ${verifiedActorUri}: ${parsed.reason}`);
470
+ break;
471
+ }
472
+ if (!config.onMove) {
473
+ logger.debug(`Unhandled Move from ${verifiedActorUri} (no onMove handler)`);
474
+ break;
475
+ }
476
+ await config.onMove(parsed.move);
477
+ break;
478
+ }
414
479
  default:
415
480
  logger.debug(`Unhandled activity type: ${validation.type}`);
416
481
  }
package/src/node/index.ts CHANGED
@@ -101,6 +101,7 @@ export {
101
101
  export {
102
102
  createInboundDispatcher,
103
103
  ActorResolutionPendingError,
104
+ type InboundMove,
104
105
  type InboundDispatcher,
105
106
  type InboundDispatcherConfig,
106
107
  type InboundDispatcherLogger,