@oxy.so/federation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/actorObject.js +216 -0
- package/dist/cjs/apContext.js +48 -0
- package/dist/cjs/apUri.js +132 -0
- package/dist/cjs/httpSignature.js +187 -0
- package/dist/cjs/index.js +99 -0
- package/dist/cjs/networkIdentity.js +487 -0
- package/dist/cjs/node/actorResolver.js +625 -0
- package/dist/cjs/node/actorRouter.js +307 -0
- package/dist/cjs/node/delivery.js +415 -0
- package/dist/cjs/node/identityBridge.js +133 -0
- package/dist/cjs/node/inboundDispatch.js +268 -0
- package/dist/cjs/node/index.js +63 -0
- package/dist/cjs/node/signedFetch.js +122 -0
- package/dist/cjs/node/webfingerRouter.js +166 -0
- package/dist/cjs/urls.js +55 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/actorObject.js +210 -0
- package/dist/esm/apContext.js +45 -0
- package/dist/esm/apUri.js +126 -0
- package/dist/esm/httpSignature.js +179 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/networkIdentity.js +472 -0
- package/dist/esm/node/actorResolver.js +620 -0
- package/dist/esm/node/actorRouter.js +304 -0
- package/dist/esm/node/delivery.js +412 -0
- package/dist/esm/node/identityBridge.js +130 -0
- package/dist/esm/node/inboundDispatch.js +263 -0
- package/dist/esm/node/index.js +51 -0
- package/dist/esm/node/signedFetch.js +119 -0
- package/dist/esm/node/webfingerRouter.js +163 -0
- package/dist/esm/urls.js +50 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/actorObject.d.ts +182 -0
- package/dist/types/apContext.d.ts +35 -0
- package/dist/types/apUri.d.ts +107 -0
- package/dist/types/httpSignature.d.ts +113 -0
- package/dist/types/index.d.ts +336 -0
- package/dist/types/networkIdentity.d.ts +509 -0
- package/dist/types/node/actorResolver.d.ts +287 -0
- package/dist/types/node/actorRouter.d.ts +108 -0
- package/dist/types/node/delivery.d.ts +248 -0
- package/dist/types/node/identityBridge.d.ts +84 -0
- package/dist/types/node/inboundDispatch.d.ts +156 -0
- package/dist/types/node/index.d.ts +51 -0
- package/dist/types/node/signedFetch.d.ts +74 -0
- package/dist/types/node/webfingerRouter.d.ts +62 -0
- package/dist/types/urls.d.ts +55 -0
- package/package.json +119 -0
- package/src/__tests__/actorObject.test.ts +258 -0
- package/src/__tests__/actorResolver.test.ts +252 -0
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
- package/src/__tests__/apUri.test.ts +53 -0
- package/src/__tests__/delivery.test.ts +432 -0
- package/src/__tests__/federationHost.test.ts +281 -0
- package/src/__tests__/httpSignature.test.ts +343 -0
- package/src/__tests__/inboundDispatch.test.ts +381 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/networkIdentity.test.ts +525 -0
- package/src/__tests__/routers.test.ts +460 -0
- package/src/__tests__/urls.test.ts +26 -0
- package/src/actorObject.ts +313 -0
- package/src/apContext.ts +45 -0
- package/src/apUri.ts +161 -0
- package/src/httpSignature.ts +282 -0
- package/src/index.ts +419 -0
- package/src/networkIdentity.ts +731 -0
- package/src/node/actorResolver.ts +839 -0
- package/src/node/actorRouter.ts +438 -0
- package/src/node/delivery.ts +729 -0
- package/src/node/identityBridge.ts +230 -0
- package/src/node/inboundDispatch.ts +420 -0
- package/src/node/index.ts +136 -0
- package/src/node/signedFetch.ts +177 -0
- package/src/node/webfingerRouter.ts +226 -0
- package/src/urls.ts +71 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `deriveNetworkIdentity` seam in the actor resolver.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour that matters is a SPLIT: a bridged actor's IDENTITY moves onto the
|
|
5
|
+
* network, while everything that ADDRESSES it over the protocol stays pointed at
|
|
6
|
+
* the bridge. Get that wrong in either direction and something breaks quietly —
|
|
7
|
+
* relabel too much and WebFinger fallback, key resolution and the domain policy
|
|
8
|
+
* all start asking `x.com` about an actor it has never heard of; relabel too
|
|
9
|
+
* little and the account renders under a hostname that tells the reader nothing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createActorResolver, type ActorResolverConfig, type FederatedActorRecordBase } from '../node/actorResolver';
|
|
13
|
+
import {
|
|
14
|
+
FEDERATION_NETWORKS,
|
|
15
|
+
createBridgeRelabeller,
|
|
16
|
+
upstreamHandleFromProfileField,
|
|
17
|
+
type DeriveNetworkIdentity,
|
|
18
|
+
} from '../networkIdentity';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A stand-in registry. This package ships no entries, so the resolver test
|
|
22
|
+
* supplies one — which is the arrangement in production too, only there the
|
|
23
|
+
* entries are an app's reviewed policy.
|
|
24
|
+
*/
|
|
25
|
+
const relabeller = createBridgeRelabeller([{
|
|
26
|
+
host: 'bird.makeup',
|
|
27
|
+
network: FEDERATION_NETWORKS.x,
|
|
28
|
+
operator: 'test',
|
|
29
|
+
software: 'BirdsiteLive',
|
|
30
|
+
derive: upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com', 'x.com'] }),
|
|
31
|
+
caseRule: 'lowercase',
|
|
32
|
+
relabel: 'enabled',
|
|
33
|
+
upstreamIdStability: 'recyclable',
|
|
34
|
+
boilerplate: [/\s*This account is a replica from Twitter\..*Patreon\.\s*$/s],
|
|
35
|
+
consent: 'unconsented',
|
|
36
|
+
evidence: 'test',
|
|
37
|
+
assumption: '',
|
|
38
|
+
since: '2026-08-02',
|
|
39
|
+
}]);
|
|
40
|
+
const deriveBridgedNetworkIdentity = relabeller.deriveNetworkIdentity;
|
|
41
|
+
import type { NormalizedExternalActor } from '../index';
|
|
42
|
+
|
|
43
|
+
interface TestActor extends FederatedActorRecordBase {
|
|
44
|
+
uri: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BRIDGED_ACTOR = {
|
|
48
|
+
id: 'https://bird.makeup/users/wired',
|
|
49
|
+
type: 'Service',
|
|
50
|
+
preferredUsername: 'wired',
|
|
51
|
+
name: 'WIRED',
|
|
52
|
+
inbox: 'https://bird.makeup/users/wired/inbox',
|
|
53
|
+
summary:
|
|
54
|
+
"The latest in tech.\nThis account is a replica from Twitter. Its author can't see your replies. "
|
|
55
|
+
+ 'If you find this service useful, please consider supporting us via our Patreon.',
|
|
56
|
+
attachment: [
|
|
57
|
+
{
|
|
58
|
+
type: 'PropertyValue',
|
|
59
|
+
name: 'Official',
|
|
60
|
+
value: '<a href="https://twitter.com/wired" rel="me nofollow noopener noreferrer">twitter.com/wired</a>',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An actor whose ENTIRE bio is the bridge's boilerplate, so stripping it leaves
|
|
67
|
+
* the empty string. Every other fixture here keeps a sentence of its own, and
|
|
68
|
+
* that is the shape that hid a real production bug: an empty result is falsy, so
|
|
69
|
+
* a `bio || undefined` coalesce discarded it and oxy-api — which only writes the
|
|
70
|
+
* field when it is sent a string — kept the boilerplate it had stored months
|
|
71
|
+
* earlier. Fixtures that all strip to something non-empty cannot tell the two
|
|
72
|
+
* readings apart. Seen live on `@elonmusk@x.com`, whose bio is nothing but the
|
|
73
|
+
* notice.
|
|
74
|
+
*/
|
|
75
|
+
const ALL_BOILERPLATE_ACTOR = {
|
|
76
|
+
...BRIDGED_ACTOR,
|
|
77
|
+
summary:
|
|
78
|
+
"This account is a replica from Twitter. Its author can't see your replies. "
|
|
79
|
+
+ 'If you find this service useful, please consider supporting us via our Patreon.',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function makeResolver(
|
|
83
|
+
deriveNetworkIdentity?: DeriveNetworkIdentity,
|
|
84
|
+
actorDocument = BRIDGED_ACTOR,
|
|
85
|
+
textOverrides: Partial<ActorResolverConfig<TestActor>['text']> = {},
|
|
86
|
+
) {
|
|
87
|
+
const upserts: Array<{ uri: string; update: Record<string, unknown> }> = [];
|
|
88
|
+
const resolved: NormalizedExternalActor[] = [];
|
|
89
|
+
const warnings: string[] = [];
|
|
90
|
+
|
|
91
|
+
const config: ActorResolverConfig<TestActor> = {
|
|
92
|
+
federationEnabled: true,
|
|
93
|
+
signedFetch: async (url) => {
|
|
94
|
+
// Collection counts are fetched too; only the actor URL returns a document.
|
|
95
|
+
if (url !== actorDocument.id) return new Response(null, { status: 404 });
|
|
96
|
+
return new Response(JSON.stringify(actorDocument), {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { 'content-type': 'application/activity+json' },
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
fetchWebFinger: async () => null,
|
|
102
|
+
isBlockedDomain: () => false,
|
|
103
|
+
normalizeFederatedAcct: (acct) => (acct ? acct.replace(/^@/, '').toLowerCase() : undefined),
|
|
104
|
+
domainFromAcct: (acct) => acct.split('@')[1],
|
|
105
|
+
firstStringUrl: () => undefined,
|
|
106
|
+
deriveNetworkIdentity,
|
|
107
|
+
store: {
|
|
108
|
+
findActorByUri: async () => null,
|
|
109
|
+
upsertActor: async (uri, update) => {
|
|
110
|
+
upserts.push({ uri, update: update as unknown as Record<string, unknown> });
|
|
111
|
+
return { uri, _id: 'row-1' };
|
|
112
|
+
},
|
|
113
|
+
findActorByPublicKeyId: async () => null,
|
|
114
|
+
setActorOxyUserId: async () => {},
|
|
115
|
+
tombstoneActor: async () => null,
|
|
116
|
+
},
|
|
117
|
+
identity: {
|
|
118
|
+
resolveExternalUser: async (actor) => {
|
|
119
|
+
resolved.push(actor);
|
|
120
|
+
return 'oxy-user-1';
|
|
121
|
+
},
|
|
122
|
+
reportActorGone: async () => 'archived',
|
|
123
|
+
},
|
|
124
|
+
text: {
|
|
125
|
+
inlineField: (value) => (typeof value === 'string' ? value : ''),
|
|
126
|
+
inlineDisplayName: (raw) => raw,
|
|
127
|
+
sanitizeFieldValue: (html) => html,
|
|
128
|
+
htmlToPlainText: (html) => html,
|
|
129
|
+
|
|
130
|
+
...textOverrides,
|
|
131
|
+
},
|
|
132
|
+
logger: { info: () => {}, warn: (message) => { warnings.push(message); } },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return { resolver: createActorResolver(config), upserts, resolved, warnings };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('deriveNetworkIdentity — the identity moves, the address does not', () => {
|
|
139
|
+
it('stores a bridged actor under the network it came from', async () => {
|
|
140
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity);
|
|
141
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
142
|
+
|
|
143
|
+
expect(rig.resolved).toHaveLength(1);
|
|
144
|
+
expect(rig.resolved[0].federatedUsername).toBe('wired@x.com');
|
|
145
|
+
expect(rig.resolved[0].instanceDomain).toBe('x.com');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps acct, uri and the stored domain pointed at the BRIDGE', async () => {
|
|
149
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity);
|
|
150
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
151
|
+
|
|
152
|
+
// `handle` is the protocol address the WebFinger fallback and the same-origin
|
|
153
|
+
// guard use; `domain` on the row is what the domain policy and every
|
|
154
|
+
// moderation consumer key off. Neither may follow the identity onto x.com.
|
|
155
|
+
expect(rig.resolved[0].handle).toBe('wired@bird.makeup');
|
|
156
|
+
expect(rig.upserts[0].update.acct).toBe('wired@bird.makeup');
|
|
157
|
+
expect(rig.upserts[0].update.domain).toBe('bird.makeup');
|
|
158
|
+
expect(rig.upserts[0].uri).toBe(BRIDGED_ACTOR.id);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('strips the bridge boilerplate from both the stored row and the Oxy identity', async () => {
|
|
162
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity);
|
|
163
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
164
|
+
|
|
165
|
+
expect(rig.upserts[0].update.summary).toBe('The latest in tech.');
|
|
166
|
+
expect(rig.resolved[0].bio).toBe('The latest in tech.');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The empty string must SURVIVE as far as the identity bridge. It is the whole
|
|
171
|
+
* instruction: oxy-api writes the bio only when it is sent a string, so
|
|
172
|
+
* dropping it means "leave whatever you stored" — and what it stored is the
|
|
173
|
+
* boilerplate. Asserting `toBe('')` rather than a falsy check on purpose;
|
|
174
|
+
* `undefined` is falsy too, and it is the wrong answer.
|
|
175
|
+
*/
|
|
176
|
+
it('still sends a bio when stripping the boilerplate empties it', async () => {
|
|
177
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity, ALL_BOILERPLATE_ACTOR);
|
|
178
|
+
await rig.resolver.fetchRemoteActor(ALL_BOILERPLATE_ACTOR.id);
|
|
179
|
+
|
|
180
|
+
expect(rig.upserts[0].update.summary).toBe('');
|
|
181
|
+
expect(rig.resolved[0].bio).toBe('');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Same mechanism, wider blast radius: an ordinary actor who DELETES their bio
|
|
186
|
+
* upstream sends `summary: ''`, which the same coalesce discarded — so a
|
|
187
|
+
* deletion never propagated and Oxy served the old text indefinitely. No
|
|
188
|
+
* bridge involved, which is why this case is here rather than only above.
|
|
189
|
+
*/
|
|
190
|
+
it('propagates a bio that was cleared upstream, with no bridge involved', async () => {
|
|
191
|
+
const rig = makeResolver(undefined, { ...BRIDGED_ACTOR, summary: '' });
|
|
192
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
193
|
+
|
|
194
|
+
expect(rig.resolved[0].bio).toBe('');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('changes nothing at all when no hook is configured', async () => {
|
|
198
|
+
const rig = makeResolver(undefined);
|
|
199
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
200
|
+
|
|
201
|
+
expect(rig.resolved[0].federatedUsername).toBe('wired@bird.makeup');
|
|
202
|
+
expect(rig.resolved[0].instanceDomain).toBe('bird.makeup');
|
|
203
|
+
expect(rig.resolved[0].bio).toBe(BRIDGED_ACTOR.summary);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('changes nothing when the hook declines the actor', async () => {
|
|
207
|
+
const rig = makeResolver(() => undefined);
|
|
208
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
209
|
+
|
|
210
|
+
expect(rig.resolved[0].federatedUsername).toBe('wired@bird.makeup');
|
|
211
|
+
expect(rig.resolved[0].instanceDomain).toBe('bird.makeup');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('deriveNetworkIdentity — results the engine refuses', () => {
|
|
216
|
+
/**
|
|
217
|
+
* oxy-api binds a federated username to its domain and rejects a mismatch, so
|
|
218
|
+
* an unbindable result must degrade to the actor's real acct rather than be
|
|
219
|
+
* sent onward. It is a bug in the app's rule, so it must also be logged, not
|
|
220
|
+
* swallowed.
|
|
221
|
+
*/
|
|
222
|
+
it.each([
|
|
223
|
+
['username under a different domain', { federatedUsername: 'wired@example.com', instanceDomain: 'x.com', bio: '' }],
|
|
224
|
+
['username with no domain at all', { federatedUsername: 'wired', instanceDomain: 'x.com', bio: '' }],
|
|
225
|
+
['empty local part', { federatedUsername: '@x.com', instanceDomain: 'x.com', bio: '' }],
|
|
226
|
+
['empty domain', { federatedUsername: 'wired@', instanceDomain: '', bio: '' }],
|
|
227
|
+
['two at-signs', { federatedUsername: 'a@b@x.com', instanceDomain: 'x.com', bio: '' }],
|
|
228
|
+
['a separator that is not an at-sign', { federatedUsername: 'wired.x.com', instanceDomain: 'x.com', bio: '' }],
|
|
229
|
+
])('refuses %s and falls back to the protocol acct', async (_label, identity) => {
|
|
230
|
+
const rig = makeResolver(() => identity);
|
|
231
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
232
|
+
|
|
233
|
+
expect(rig.resolved[0].federatedUsername).toBe('wired@bird.makeup');
|
|
234
|
+
expect(rig.resolved[0].instanceDomain).toBe('bird.makeup');
|
|
235
|
+
expect(rig.warnings.some((w) => w.includes('not bindable'))).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('accepts a bindable result that differs only in case', async () => {
|
|
239
|
+
const rig = makeResolver(() => ({ federatedUsername: 'WIRED@X.com', instanceDomain: 'X.com', bio: 'x' }));
|
|
240
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
241
|
+
|
|
242
|
+
expect(rig.resolved[0].federatedUsername).toBe('wired@x.com');
|
|
243
|
+
expect(rig.resolved[0].instanceDomain).toBe('x.com');
|
|
244
|
+
expect(rig.warnings.some((w) => w.includes('not bindable'))).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A handle an actor wrote in its own bio only means something beside the network
|
|
250
|
+
* it was written on. Copied across verbatim, `@openai` reads on the receiving
|
|
251
|
+
* server as a LOCAL name — so a reader is pointed at whoever holds that name
|
|
252
|
+
* here. Seen live: Mira Murati's synced bio read "Now building @thinkymachines.
|
|
253
|
+
* Previously CTO @openai", both accounts on X, both rendered as if local.
|
|
254
|
+
*
|
|
255
|
+
* The engine owns WHERE the hook applies; the app owns what a handle is.
|
|
256
|
+
*/
|
|
257
|
+
describe('bio handle qualification', () => {
|
|
258
|
+
/** A stand-in for the app's rule — the real one is Mention's entity scanner. */
|
|
259
|
+
const qualifyHandles = (text: string, domain: string): string =>
|
|
260
|
+
text.replace(/@([A-Za-z0-9_]+)(?!@)/g, `@$1@${domain}`);
|
|
261
|
+
|
|
262
|
+
it('qualifies against the NETWORK domain for a relabelled actor', async () => {
|
|
263
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity, {
|
|
264
|
+
...BRIDGED_ACTOR,
|
|
265
|
+
summary: 'Now building @thinkymachines'
|
|
266
|
+
+ "\nThis account is a replica from Twitter. Its author can't see your replies. "
|
|
267
|
+
+ 'If you find this service useful, please consider supporting us via our Patreon.',
|
|
268
|
+
}, { qualifyHandles });
|
|
269
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
270
|
+
|
|
271
|
+
// x.com, NOT bird.makeup: the handle belongs to the network the account is
|
|
272
|
+
// on, never to the bridge the copy happened to arrive through.
|
|
273
|
+
expect(rig.resolved[0].bio).toBe('Now building @thinkymachines@x.com');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('writes the SAME qualified text to the stored row and to Oxy', async () => {
|
|
277
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity, {
|
|
278
|
+
...BRIDGED_ACTOR,
|
|
279
|
+
summary: 'CTO @openai',
|
|
280
|
+
}, { qualifyHandles });
|
|
281
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
282
|
+
|
|
283
|
+
// The point of applying it once: two writes that could disagree, cannot.
|
|
284
|
+
expect(rig.upserts[0].update.summary).toBe('CTO @openai@x.com');
|
|
285
|
+
expect(rig.resolved[0].bio).toBe(rig.upserts[0].update.summary);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('changes nothing at all for an app that supplies no rule', async () => {
|
|
289
|
+
const rig = makeResolver(deriveBridgedNetworkIdentity, {
|
|
290
|
+
...BRIDGED_ACTOR,
|
|
291
|
+
summary: 'CTO @openai',
|
|
292
|
+
});
|
|
293
|
+
await rig.resolver.fetchRemoteActor(BRIDGED_ACTOR.id);
|
|
294
|
+
|
|
295
|
+
expect(rig.resolved[0].bio).toBe('CTO @openai');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createDomainPolicy, extractActorUriFromActivityId } from '../index';
|
|
2
|
+
|
|
3
|
+
const policy = createDomainPolicy({
|
|
4
|
+
domain: 'mention.earth',
|
|
5
|
+
identityApex: 'oxy.so',
|
|
6
|
+
blockedDomains: ['spam.example'],
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('createDomainPolicy.isBlockedDomain', () => {
|
|
10
|
+
it('blocks our own federation domain and the Oxy identity apex (case-insensitively)', () => {
|
|
11
|
+
expect(policy.isBlockedDomain('mention.earth')).toBe(true);
|
|
12
|
+
expect(policy.isBlockedDomain('www.mention.earth')).toBe(true);
|
|
13
|
+
expect(policy.isBlockedDomain('oxy.so')).toBe(true);
|
|
14
|
+
expect(policy.isBlockedDomain('OXY.SO')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('blocks an explicitly-configured domain', () => {
|
|
18
|
+
expect(policy.isBlockedDomain('spam.example')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not block legitimate remote domains (substring-safe)', () => {
|
|
22
|
+
expect(policy.isBlockedDomain('mastodon.social')).toBe(false);
|
|
23
|
+
expect(policy.isBlockedDomain('threads.net')).toBe(false);
|
|
24
|
+
expect(policy.isBlockedDomain('oxy.so.evil.example')).toBe(false);
|
|
25
|
+
expect(policy.isBlockedDomain('notoxy.so')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('createDomainPolicy.extractLocalPostId', () => {
|
|
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('abc123');
|
|
32
|
+
expect(policy.extractLocalPostId('https://mention.earth/ap/users/nate/posts/abc123/')).toBe('abc123');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null for a remote URI or a non-matching path', () => {
|
|
36
|
+
expect(policy.extractLocalPostId('https://mastodon.social/users/alice/statuses/1')).toBeNull();
|
|
37
|
+
expect(policy.extractLocalPostId('https://mention.earth/@nate')).toBeNull();
|
|
38
|
+
expect(policy.extractLocalPostId('not a url')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('extractActorUriFromActivityId', () => {
|
|
43
|
+
it('trims from the first post-path segment to yield the actor uri', () => {
|
|
44
|
+
expect(extractActorUriFromActivityId('https://mastodon.social/users/alice/statuses/12345')).toBe(
|
|
45
|
+
'https://mastodon.social/users/alice',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns null when malformed or no post-path segment is present', () => {
|
|
50
|
+
expect(extractActorUriFromActivityId('https://mastodon.social/users/alice')).toBeNull();
|
|
51
|
+
expect(extractActorUriFromActivityId('garbage')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|