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