@oxy.so/federation 2.2.1 → 2.3.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/apUri.js +4 -2
- package/dist/cjs/httpSignature.js +1 -1
- package/dist/cjs/index.js +2 -2
- package/dist/cjs/networkIdentity.js +3 -2
- package/dist/cjs/node/actorResolver.js +24 -20
- package/dist/cjs/node/actorRouter.js +12 -4
- package/dist/cjs/node/delivery.js +7 -4
- package/dist/cjs/node/inboundDispatch.js +4 -3
- package/dist/cjs/node/index.js +7 -1
- package/dist/cjs/node/tcoLinks.js +97 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/apUri.js +4 -2
- package/dist/esm/httpSignature.js +1 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/networkIdentity.js +3 -2
- package/dist/esm/node/actorResolver.js +24 -20
- package/dist/esm/node/actorRouter.js +12 -4
- package/dist/esm/node/delivery.js +7 -4
- package/dist/esm/node/inboundDispatch.js +4 -3
- package/dist/esm/node/index.js +5 -0
- package/dist/esm/node/tcoLinks.js +94 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/httpSignature.d.ts +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/node/delivery.d.ts +2 -3
- package/dist/types/node/index.d.ts +5 -0
- package/dist/types/node/tcoLinks.d.ts +41 -0
- package/package.json +2 -3
- package/src/__tests__/actorCollectionCounts.test.ts +27 -18
- package/src/__tests__/actorObject.test.ts +27 -7
- package/src/__tests__/actorResolver.test.ts +37 -22
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
- package/src/__tests__/apUri.test.ts +9 -5
- package/src/__tests__/delivery.test.ts +56 -24
- package/src/__tests__/federationHost.test.ts +13 -3
- package/src/__tests__/httpSignature.test.ts +1 -3
- package/src/__tests__/inboundDispatch.test.ts +80 -30
- package/src/__tests__/networkIdentity.test.ts +240 -113
- package/src/__tests__/routers.test.ts +87 -27
- package/src/__tests__/tcoLinks.test.ts +21 -0
- package/src/actorObject.ts +8 -3
- package/src/apUri.ts +6 -2
- package/src/httpSignature.ts +1 -1
- package/src/index.ts +8 -3
- package/src/networkIdentity.ts +14 -5
- package/src/node/actorResolver.ts +91 -50
- package/src/node/actorRouter.ts +38 -12
- package/src/node/delivery.ts +55 -21
- package/src/node/identityBridge.ts +38 -18
- package/src/node/inboundDispatch.ts +33 -13
- package/src/node/index.ts +11 -0
- package/src/node/signedFetch.ts +3 -1
- package/src/node/tcoLinks.ts +114 -0
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
|
|
17
17
|
* passes `true`; a directly-exposed origin leaves it `false`.
|
|
18
18
|
*
|
|
19
|
-
* Lives in the isomorphic `.` entry (no Express /
|
|
19
|
+
* Lives in the isomorphic `.` entry (no Express / database driver): it depends only on
|
|
20
20
|
* the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
|
|
21
21
|
* React-Native bundles — RN consumers import only the connector TYPES, which are
|
|
22
22
|
* erased at compile time.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* connector produces, the local-event union connectors deliver outbound, and
|
|
9
9
|
* the connector interface itself.
|
|
10
10
|
*
|
|
11
|
-
* IMPORTANT: this entry is intentionally free of
|
|
12
|
-
* Native so it can be imported from any Oxy app backend (and, in later phases,
|
|
11
|
+
* IMPORTANT: this entry is intentionally free of database drivers / Express /
|
|
12
|
+
* React Native so it can be imported from any Oxy app backend (and, in later phases,
|
|
13
13
|
* share the pure HTTP-signature + actor-object surface with browser/isomorphic
|
|
14
14
|
* callers). The runnable Express/Node engine — signed fetch, delivery transport,
|
|
15
15
|
* webfinger/actor/inbox routers, remote-actor resolution — lives under the
|
|
@@ -30,7 +30,7 @@ export { signRequest, verifyHttpSignature, HTTP_SIGNATURE_ALGORITHM, DEFAULT_SIG
|
|
|
30
30
|
* Domain-parameterized ActivityPub URL builders — each app instantiates them once
|
|
31
31
|
* with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
|
|
32
32
|
*/
|
|
33
|
-
export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, type UrlBuilders } from './urls';
|
|
33
|
+
export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, type UrlBuilders, } from './urls';
|
|
34
34
|
/**
|
|
35
35
|
* Network identity: the MECHANISM for re-labelling an account republished by a
|
|
36
36
|
* bridge onto the network it actually came from, plus the network vocabulary and
|
|
@@ -79,9 +79,8 @@ export interface DeliveryQueueJob {
|
|
|
79
79
|
* The durable-delivery fallback (written when BullMQ is unavailable).
|
|
80
80
|
*
|
|
81
81
|
* App-supplied, like every other store in this package: the engine never names
|
|
82
|
-
* a database. Mention backs it with Postgres; the method names below are
|
|
83
|
-
*
|
|
84
|
-
* adapter shape stays stable for consumers.
|
|
82
|
+
* a database. Mention backs it with Postgres; the method names below are kept
|
|
83
|
+
* stable so the adapter shape stays stable for consumers.
|
|
85
84
|
*/
|
|
86
85
|
export interface DeliveryFallbackQueue {
|
|
87
86
|
/** Insert one fallback delivery row (`queueDelivery`). */
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* actor + inbox + follow-graph Express routers.
|
|
18
18
|
*/
|
|
19
19
|
export { createSignedFetch, type SignedFetch, type CreateSignedFetchConfig, type SingleHopFetch, type SingleHopFetchInit, type SignedFetchLogger, } from './signedFetch';
|
|
20
|
+
/**
|
|
21
|
+
* X's `t.co` wrapper unwrapped into the link the author posted, over the same
|
|
22
|
+
* injected single-hop transport — for bridged X bios and posts.
|
|
23
|
+
*/
|
|
24
|
+
export { createTcoLinkExpander, type TcoLinkExpander, type TcoLinkCache, type CreateTcoLinkExpanderConfig, } from './tcoLinks';
|
|
20
25
|
/**
|
|
21
26
|
* The actor↔Oxy-user identity bridge — the default implementation of the
|
|
22
27
|
* `PUT /users/resolve` + actor-gone archive/delete seam over an injected
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expanding X's `t.co` link wrapper back into the link the author posted.
|
|
3
|
+
*
|
|
4
|
+
* X rewrites EVERY link in a post or bio into `https://t.co/<id>`, and a bridge
|
|
5
|
+
* that mirrors an X account (bird.makeup, mastox) republishes that text as it
|
|
6
|
+
* is: the actor `summary` and the Note `content` carry only the wrapper. The
|
|
7
|
+
* author never wrote `t.co`; it is X's click tracker standing in for their link,
|
|
8
|
+
* and an X API client never shows it because it reads the expansion beside it.
|
|
9
|
+
* The bridges drop that expansion, so the only place left to recover it is the
|
|
10
|
+
* wrapper's own redirect.
|
|
11
|
+
*
|
|
12
|
+
* Only `t.co` is unwrapped, and only ONE hop. A `bit.ly` the author chose is
|
|
13
|
+
* their link and stays; so does wherever their link redirects next. What is
|
|
14
|
+
* replaced is exactly X's wrapper, by exactly the URL it names.
|
|
15
|
+
*
|
|
16
|
+
* The transport is injected — the same SSRF-safe, IP-pinned single-hop fetch
|
|
17
|
+
* {@link createSignedFetch} takes — so each backend keeps its own network
|
|
18
|
+
* contract. A link that does not resolve (timeout, 404, a malformed or
|
|
19
|
+
* self-referencing `Location`) is left as `t.co`: still a working link, never a
|
|
20
|
+
* broken one.
|
|
21
|
+
*/
|
|
22
|
+
import type { SingleHopFetch } from './signedFetch';
|
|
23
|
+
/**
|
|
24
|
+
* A store of resolved wrappers, keyed by the `t.co` id. A wrapper never changes
|
|
25
|
+
* what it points to, so an entry never goes stale. Failures are not cached.
|
|
26
|
+
*/
|
|
27
|
+
export interface TcoLinkCache {
|
|
28
|
+
get(id: string): Promise<string | null | undefined>;
|
|
29
|
+
set(id: string, target: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** Adapters a {@link TcoLinkExpander} is built from. */
|
|
32
|
+
export interface CreateTcoLinkExpanderConfig {
|
|
33
|
+
/** SSRF-safe single-hop transport (does NOT follow redirects). */
|
|
34
|
+
fetchSingleHop: SingleHopFetch;
|
|
35
|
+
/** User-Agent presented to t.co. A browser UA gets an HTML page instead of the 301. */
|
|
36
|
+
userAgent: string;
|
|
37
|
+
cache?: TcoLinkCache;
|
|
38
|
+
}
|
|
39
|
+
/** Replace every `t.co` link in `text` with the URL it redirects to. */
|
|
40
|
+
export type TcoLinkExpander = (text: string) => Promise<string>;
|
|
41
|
+
export declare function createTcoLinkExpander(config: CreateTcoLinkExpanderConfig): TcoLinkExpander;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0",
|
|
96
|
+
"@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || 4.11.0-oxy-one.0",
|
|
97
97
|
"@oxy.so/core": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
@@ -105,7 +105,6 @@
|
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@biomejs/biome": "^1.9.4",
|
|
109
108
|
"@types/express": "^4.17.25",
|
|
110
109
|
"@types/jest": "^30.0.0",
|
|
111
110
|
"@types/node": "^22.20.1",
|
|
@@ -114,26 +114,32 @@ describe('fetchRemoteActor — remote collection counts', () => {
|
|
|
114
114
|
expect(upserts[0]).toMatchObject({ followersCount: 1234, followingCount: 0, postsCount: 56 });
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it.each([401, 403, 404, 410])(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
it.each([401, 403, 404, 410])(
|
|
118
|
+
'records a collection that answers %i as unknown (null), not 0',
|
|
119
|
+
async (status) => {
|
|
120
|
+
const { upserts } = await resolveWith({
|
|
121
|
+
followers: new Response(null, { status }),
|
|
122
|
+
following: collection(3),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(upserts[0]?.followersCount).toBeNull();
|
|
126
|
+
expect(upserts[0]?.followingCount).toBe(3);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
126
129
|
|
|
127
130
|
it.each([
|
|
128
131
|
['missing', undefined],
|
|
129
132
|
['a string', '1234'],
|
|
130
133
|
['negative', -1],
|
|
131
134
|
['fractional', 1.5],
|
|
132
|
-
])(
|
|
133
|
-
|
|
135
|
+
])(
|
|
136
|
+
'records a collection whose totalItems is %s as unknown (null)',
|
|
137
|
+
async (_label, totalItems) => {
|
|
138
|
+
const { upserts } = await resolveWith({ followers: collection(totalItems) });
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
expect(upserts[0]?.followersCount).toBeNull();
|
|
141
|
+
},
|
|
142
|
+
);
|
|
137
143
|
|
|
138
144
|
it('records an actor that advertises no collection as unknown (null)', async () => {
|
|
139
145
|
const { upserts } = await resolveWith({
|
|
@@ -150,12 +156,15 @@ describe('fetchRemoteActor — remote collection counts', () => {
|
|
|
150
156
|
['a 429', () => Promise.resolve(new Response(null, { status: 429 }))],
|
|
151
157
|
['malformed JSON', () => Promise.resolve(new Response('{"totalItems": 12'))],
|
|
152
158
|
['an empty body', () => Promise.resolve(new Response(''))],
|
|
153
|
-
] as const)(
|
|
154
|
-
|
|
159
|
+
] as const)(
|
|
160
|
+
'OMITS a count whose fetch failed with %s, so a known value is kept',
|
|
161
|
+
async (_label, followers) => {
|
|
162
|
+
const { upserts } = await resolveWith({ followers, following: collection(9) });
|
|
155
163
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
expect(upserts[0]).not.toHaveProperty('followersCount');
|
|
165
|
+
expect(upserts[0]?.followingCount).toBe(9);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
159
168
|
|
|
160
169
|
it('never hands the identity bridge a zero it did not read', async () => {
|
|
161
170
|
const { bridged } = await resolveWith({
|
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
|
|
24
24
|
/** A media resolver that returns fixed absolute CDN URLs (avatar png, banner jpg). */
|
|
25
25
|
const media: ActorMediaResolver = {
|
|
26
|
-
resolveAvatar: (ref) =>
|
|
27
|
-
|
|
26
|
+
resolveAvatar: (ref) =>
|
|
27
|
+
ref === 'avatar-file-id' ? 'https://cloud.oxy.so/media/nate-avatar.png' : undefined,
|
|
28
|
+
resolveBanner: (ref) =>
|
|
29
|
+
ref === 'banner-file-id' ? 'https://cloud.oxy.so/media/nate-banner.jpg' : undefined,
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
const buildActor = createLocalActorBuilder({
|
|
@@ -68,8 +70,16 @@ const EXPECTED_ACTOR: Record<string, unknown> = {
|
|
|
68
70
|
endpoints: { sharedInbox: 'https://mention.earth/ap/inbox' },
|
|
69
71
|
discoverable: true,
|
|
70
72
|
manuallyApprovesFollowers: false,
|
|
71
|
-
icon: {
|
|
72
|
-
|
|
73
|
+
icon: {
|
|
74
|
+
type: 'Image',
|
|
75
|
+
url: 'https://cloud.oxy.so/media/nate-avatar.png',
|
|
76
|
+
mediaType: 'image/png',
|
|
77
|
+
},
|
|
78
|
+
image: {
|
|
79
|
+
type: 'Image',
|
|
80
|
+
url: 'https://cloud.oxy.so/media/nate-banner.jpg',
|
|
81
|
+
mediaType: 'image/jpeg',
|
|
82
|
+
},
|
|
73
83
|
publicKey: {
|
|
74
84
|
id: 'https://mention.earth/ap/users/nate#main-key',
|
|
75
85
|
owner: 'https://mention.earth/ap/users/nate',
|
|
@@ -88,7 +98,9 @@ describe('createLocalActorBuilder (golden actor vector)', () => {
|
|
|
88
98
|
|
|
89
99
|
it('serves the byte-identical document once wrapped in the route @context', () => {
|
|
90
100
|
const served = { '@context': AP_CONTEXT, ...buildActor(PARAMS) };
|
|
91
|
-
expect(JSON.stringify(served)).toBe(
|
|
101
|
+
expect(JSON.stringify(served)).toBe(
|
|
102
|
+
JSON.stringify({ '@context': AP_CONTEXT, ...EXPECTED_ACTOR }),
|
|
103
|
+
);
|
|
92
104
|
// publicKey.id host MUST equal the actor id host (Mastodon rejects a cross-domain key).
|
|
93
105
|
const key = served.publicKey as { id: string };
|
|
94
106
|
expect(new URL(key.id).host).toBe(new URL(served.id as string).host);
|
|
@@ -263,7 +275,13 @@ describe('createLocalActorBuilder — alsoKnownAs', () => {
|
|
|
263
275
|
it('emits the aliases after publicKey, de-duplicated and https-only', () => {
|
|
264
276
|
const actor = buildActor({
|
|
265
277
|
...PARAMS,
|
|
266
|
-
alsoKnownAs: [
|
|
278
|
+
alsoKnownAs: [
|
|
279
|
+
alias,
|
|
280
|
+
'http://insecure.example/users/nate',
|
|
281
|
+
'not a url',
|
|
282
|
+
alias,
|
|
283
|
+
'https://pleroma.example/users/n',
|
|
284
|
+
],
|
|
267
285
|
});
|
|
268
286
|
expect(actor.alsoKnownAs).toEqual([alias, 'https://pleroma.example/users/n']);
|
|
269
287
|
const keys = Object.keys(actor);
|
|
@@ -271,7 +289,9 @@ describe('createLocalActorBuilder — alsoKnownAs', () => {
|
|
|
271
289
|
});
|
|
272
290
|
|
|
273
291
|
it('emits nothing when every alias is unpublishable', () => {
|
|
274
|
-
expect(buildActor({ ...PARAMS, alsoKnownAs: ['http://x.example/u'] })).not.toHaveProperty(
|
|
292
|
+
expect(buildActor({ ...PARAMS, alsoKnownAs: ['http://x.example/u'] })).not.toHaveProperty(
|
|
293
|
+
'alsoKnownAs',
|
|
294
|
+
);
|
|
275
295
|
});
|
|
276
296
|
});
|
|
277
297
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createDomainPolicy } from '../apUri';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createActorResolver,
|
|
4
|
+
type ActorResolverConfig,
|
|
5
|
+
type FederatedActorRecordBase,
|
|
6
|
+
} from '../node/actorResolver';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* The instance domain policy applies to CACHED actors, not just fetched ones.
|
|
@@ -90,17 +94,23 @@ function makeResolver(
|
|
|
90
94
|
* the work: a reader that stopped at the cap has pulled a handful of chunks; one
|
|
91
95
|
* that drained has pulled all of them.
|
|
92
96
|
*/
|
|
93
|
-
function streamedResponse(
|
|
97
|
+
function streamedResponse(
|
|
98
|
+
chunks: string[],
|
|
99
|
+
init?: ResponseInit,
|
|
100
|
+
): { response: Response; pulls: () => number } {
|
|
94
101
|
let pulls = 0;
|
|
95
102
|
const encoder = new TextEncoder();
|
|
96
|
-
const response = new Response(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const response = new Response(
|
|
104
|
+
new ReadableStream<Uint8Array>({
|
|
105
|
+
pull(controller) {
|
|
106
|
+
pulls += 1;
|
|
107
|
+
const chunk = chunks.shift();
|
|
108
|
+
if (chunk === undefined) controller.close();
|
|
109
|
+
else controller.enqueue(encoder.encode(chunk));
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
init,
|
|
113
|
+
);
|
|
104
114
|
return { response, pulls: () => pulls };
|
|
105
115
|
}
|
|
106
116
|
|
|
@@ -176,7 +186,10 @@ describe('fetchRemoteActor — bounded remote bodies', () => {
|
|
|
176
186
|
* stops at the cap pulls a handful; one that drains pulls all `count`.
|
|
177
187
|
*/
|
|
178
188
|
function chunked(chunkBytes: number, count: number, init?: ResponseInit) {
|
|
179
|
-
return streamedResponse(
|
|
189
|
+
return streamedResponse(
|
|
190
|
+
Array.from({ length: count }, () => 'x'.repeat(chunkBytes)),
|
|
191
|
+
init,
|
|
192
|
+
);
|
|
180
193
|
}
|
|
181
194
|
|
|
182
195
|
it('stops reading an actor body once it exceeds the decompressed-size cap', async () => {
|
|
@@ -210,14 +223,17 @@ describe('fetchRemoteActor — bounded remote bodies', () => {
|
|
|
210
223
|
|
|
211
224
|
it('rejects an oversized actor from Content-Length without consuming its stream', async () => {
|
|
212
225
|
let cancelled = false;
|
|
213
|
-
const response = new Response(
|
|
214
|
-
|
|
215
|
-
controller
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
const response = new Response(
|
|
227
|
+
new ReadableStream<Uint8Array>({
|
|
228
|
+
pull(controller) {
|
|
229
|
+
controller.enqueue(new Uint8Array([123]));
|
|
230
|
+
},
|
|
231
|
+
cancel() {
|
|
232
|
+
cancelled = true;
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
{ headers: { 'content-length': String(1024 * 1024 + 1) } },
|
|
236
|
+
);
|
|
221
237
|
const rig = makeResolver({ signedFetch: async () => response });
|
|
222
238
|
|
|
223
239
|
await expect(rig.resolver.fetchRemoteActor(ALLOWED_ACTOR)).resolves.toBeNull();
|
|
@@ -233,9 +249,8 @@ describe('fetchRemoteActor — bounded remote bodies', () => {
|
|
|
233
249
|
});
|
|
234
250
|
const oversizedCollection = chunked(8 * 1024, 24);
|
|
235
251
|
const rig = makeResolver({
|
|
236
|
-
signedFetch: async (url) =>
|
|
237
|
-
? new Response(actor)
|
|
238
|
-
: oversizedCollection.response,
|
|
252
|
+
signedFetch: async (url) =>
|
|
253
|
+
url === ALLOWED_ACTOR ? new Response(actor) : oversizedCollection.response,
|
|
239
254
|
});
|
|
240
255
|
|
|
241
256
|
await expect(rig.resolver.fetchRemoteActor(ALLOWED_ACTOR)).resolves.toBeNull();
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* little and the account renders under a hostname that tells the reader nothing.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createActorResolver,
|
|
14
|
+
type ActorResolverConfig,
|
|
15
|
+
type FederatedActorRecordBase,
|
|
16
|
+
} from '../node/actorResolver';
|
|
13
17
|
import {
|
|
14
18
|
FEDERATION_NETWORKS,
|
|
15
19
|
createBridgeRelabeller,
|
|
@@ -22,21 +26,26 @@ import {
|
|
|
22
26
|
* supplies one — which is the arrangement in production too, only there the
|
|
23
27
|
* entries are an app's reviewed policy.
|
|
24
28
|
*/
|
|
25
|
-
const relabeller = createBridgeRelabeller([
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
const relabeller = createBridgeRelabeller([
|
|
30
|
+
{
|
|
31
|
+
host: 'bird.makeup',
|
|
32
|
+
network: FEDERATION_NETWORKS.x,
|
|
33
|
+
operator: 'test',
|
|
34
|
+
software: 'BirdsiteLive',
|
|
35
|
+
derive: upstreamHandleFromProfileField({
|
|
36
|
+
fieldName: 'Official',
|
|
37
|
+
hosts: ['twitter.com', 'x.com'],
|
|
38
|
+
}),
|
|
39
|
+
caseRule: 'lowercase',
|
|
40
|
+
relabel: 'enabled',
|
|
41
|
+
upstreamIdStability: 'recyclable',
|
|
42
|
+
boilerplate: [/\s*This account is a replica from Twitter\..*Patreon\.\s*$/s],
|
|
43
|
+
consent: 'unconsented',
|
|
44
|
+
evidence: 'test',
|
|
45
|
+
assumption: '',
|
|
46
|
+
since: '2026-08-02',
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
40
49
|
const deriveBridgedNetworkIdentity = relabeller.deriveNetworkIdentity;
|
|
41
50
|
import type { NormalizedExternalActor } from '../index';
|
|
42
51
|
|
|
@@ -51,13 +60,14 @@ const BRIDGED_ACTOR = {
|
|
|
51
60
|
name: 'WIRED',
|
|
52
61
|
inbox: 'https://bird.makeup/users/wired/inbox',
|
|
53
62
|
summary:
|
|
54
|
-
"The latest in tech.\nThis account is a replica from Twitter. Its author can't see your replies. "
|
|
55
|
-
|
|
63
|
+
"The latest in tech.\nThis account is a replica from Twitter. Its author can't see your replies. " +
|
|
64
|
+
'If you find this service useful, please consider supporting us via our Patreon.',
|
|
56
65
|
attachment: [
|
|
57
66
|
{
|
|
58
67
|
type: 'PropertyValue',
|
|
59
68
|
name: 'Official',
|
|
60
|
-
value:
|
|
69
|
+
value:
|
|
70
|
+
'<a href="https://twitter.com/wired" rel="me nofollow noopener noreferrer">twitter.com/wired</a>',
|
|
61
71
|
},
|
|
62
72
|
],
|
|
63
73
|
};
|
|
@@ -75,8 +85,8 @@ const BRIDGED_ACTOR = {
|
|
|
75
85
|
const ALL_BOILERPLATE_ACTOR = {
|
|
76
86
|
...BRIDGED_ACTOR,
|
|
77
87
|
summary:
|
|
78
|
-
"This account is a replica from Twitter. Its author can't see your replies. "
|
|
79
|
-
|
|
88
|
+
"This account is a replica from Twitter. Its author can't see your replies. " +
|
|
89
|
+
'If you find this service useful, please consider supporting us via our Patreon.',
|
|
80
90
|
};
|
|
81
91
|
|
|
82
92
|
function makeResolver(
|
|
@@ -129,7 +139,12 @@ function makeResolver(
|
|
|
129
139
|
|
|
130
140
|
...textOverrides,
|
|
131
141
|
},
|
|
132
|
-
logger: {
|
|
142
|
+
logger: {
|
|
143
|
+
info: () => {},
|
|
144
|
+
warn: (message) => {
|
|
145
|
+
warnings.push(message);
|
|
146
|
+
},
|
|
147
|
+
},
|
|
133
148
|
};
|
|
134
149
|
|
|
135
150
|
return { resolver: createActorResolver(config), upserts, resolved, warnings };
|
|
@@ -220,12 +235,21 @@ describe('deriveNetworkIdentity — results the engine refuses', () => {
|
|
|
220
235
|
* swallowed.
|
|
221
236
|
*/
|
|
222
237
|
it.each([
|
|
223
|
-
[
|
|
224
|
-
|
|
238
|
+
[
|
|
239
|
+
'username under a different domain',
|
|
240
|
+
{ federatedUsername: 'wired@example.com', instanceDomain: 'x.com', bio: '' },
|
|
241
|
+
],
|
|
242
|
+
[
|
|
243
|
+
'username with no domain at all',
|
|
244
|
+
{ federatedUsername: 'wired', instanceDomain: 'x.com', bio: '' },
|
|
245
|
+
],
|
|
225
246
|
['empty local part', { federatedUsername: '@x.com', instanceDomain: 'x.com', bio: '' }],
|
|
226
247
|
['empty domain', { federatedUsername: 'wired@', instanceDomain: '', bio: '' }],
|
|
227
248
|
['two at-signs', { federatedUsername: 'a@b@x.com', instanceDomain: 'x.com', bio: '' }],
|
|
228
|
-
[
|
|
249
|
+
[
|
|
250
|
+
'a separator that is not an at-sign',
|
|
251
|
+
{ federatedUsername: 'wired.x.com', instanceDomain: 'x.com', bio: '' },
|
|
252
|
+
],
|
|
229
253
|
])('refuses %s and falls back to the protocol acct', async (_label, identity) => {
|
|
230
254
|
const rig = makeResolver(() => identity);
|
|
231
255
|
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
@@ -236,7 +260,11 @@ describe('deriveNetworkIdentity — results the engine refuses', () => {
|
|
|
236
260
|
});
|
|
237
261
|
|
|
238
262
|
it('accepts a bindable result that differs only in case', async () => {
|
|
239
|
-
const rig = makeResolver(() => ({
|
|
263
|
+
const rig = makeResolver(() => ({
|
|
264
|
+
federatedUsername: 'WIRED@X.com',
|
|
265
|
+
instanceDomain: 'X.com',
|
|
266
|
+
bio: 'x',
|
|
267
|
+
}));
|
|
240
268
|
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
241
269
|
|
|
242
270
|
expect(rig.resolved[0].federatedUsername).toBe('wired@x.com');
|
|
@@ -260,12 +288,17 @@ describe('bio handle qualification', () => {
|
|
|
260
288
|
text.replace(/@([A-Za-z0-9_]+)(?!@)/g, `@$1@${domain}`);
|
|
261
289
|
|
|
262
290
|
it('qualifies against the NETWORK domain for a relabelled actor', async () => {
|
|
263
|
-
const rig = makeResolver(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
291
|
+
const rig = makeResolver(
|
|
292
|
+
deriveBridgedNetworkIdentity,
|
|
293
|
+
{
|
|
294
|
+
...BRIDGED_ACTOR,
|
|
295
|
+
summary:
|
|
296
|
+
'Now building @thinkymachines' +
|
|
297
|
+
"\nThis account is a replica from Twitter. Its author can't see your replies. " +
|
|
298
|
+
'If you find this service useful, please consider supporting us via our Patreon.',
|
|
299
|
+
},
|
|
300
|
+
{ qualifyHandles },
|
|
301
|
+
);
|
|
269
302
|
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
270
303
|
|
|
271
304
|
// x.com, NOT bird.makeup: the handle belongs to the network the account is
|
|
@@ -274,10 +307,14 @@ describe('bio handle qualification', () => {
|
|
|
274
307
|
});
|
|
275
308
|
|
|
276
309
|
it('writes the SAME qualified text to the stored row and to Oxy', async () => {
|
|
277
|
-
const rig = makeResolver(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
310
|
+
const rig = makeResolver(
|
|
311
|
+
deriveBridgedNetworkIdentity,
|
|
312
|
+
{
|
|
313
|
+
...BRIDGED_ACTOR,
|
|
314
|
+
summary: 'CTO @openai',
|
|
315
|
+
},
|
|
316
|
+
{ qualifyHandles },
|
|
317
|
+
);
|
|
281
318
|
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
282
319
|
|
|
283
320
|
// The point of applying it once: two writes that could disagree, cannot.
|
|
@@ -28,8 +28,12 @@ describe('createDomainPolicy.isBlockedDomain', () => {
|
|
|
28
28
|
|
|
29
29
|
describe('createDomainPolicy.extractLocalPostId', () => {
|
|
30
30
|
it('extracts the post id from one of our own AP object URIs', () => {
|
|
31
|
-
expect(policy.extractLocalPostId('https://mention.earth/ap/users/nate/posts/abc123')).toBe(
|
|
32
|
-
|
|
31
|
+
expect(policy.extractLocalPostId('https://mention.earth/ap/users/nate/posts/abc123')).toBe(
|
|
32
|
+
'abc123',
|
|
33
|
+
);
|
|
34
|
+
expect(policy.extractLocalPostId('https://mention.earth/ap/users/nate/posts/abc123/')).toBe(
|
|
35
|
+
'abc123',
|
|
36
|
+
);
|
|
33
37
|
});
|
|
34
38
|
|
|
35
39
|
it('returns null for a remote URI or a non-matching path', () => {
|
|
@@ -41,9 +45,9 @@ describe('createDomainPolicy.extractLocalPostId', () => {
|
|
|
41
45
|
|
|
42
46
|
describe('extractActorUriFromActivityId', () => {
|
|
43
47
|
it('trims from the first post-path segment to yield the actor uri', () => {
|
|
44
|
-
expect(
|
|
45
|
-
'https://mastodon.social/users/alice',
|
|
46
|
-
);
|
|
48
|
+
expect(
|
|
49
|
+
extractActorUriFromActivityId('https://mastodon.social/users/alice/statuses/12345'),
|
|
50
|
+
).toBe('https://mastodon.social/users/alice');
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
it('returns null when malformed or no post-path segment is present', () => {
|