@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,258 @@
|
|
|
1
|
+
import { ACCOUNT_KINDS } from '@oxy.so/contracts';
|
|
2
|
+
import {
|
|
3
|
+
AP_CONTEXT,
|
|
4
|
+
createLocalActorBuilder,
|
|
5
|
+
createUrlBuilders,
|
|
6
|
+
AP_ACTOR_TYPES,
|
|
7
|
+
isApActorType,
|
|
8
|
+
LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
|
|
9
|
+
localActorTypeForAccountKind,
|
|
10
|
+
type ActorMediaResolver,
|
|
11
|
+
} from '../index';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GOLDEN VECTOR — the exact `Person` actor document the engine emits.
|
|
15
|
+
*
|
|
16
|
+
* The bytes of this document are load-bearing: Mastodon negative-caches a
|
|
17
|
+
* malformed actor for minutes/hours, so ANY drift in the field set, key ORDER,
|
|
18
|
+
* `@context` terms, URL shapes, or the `publicKey` (id host == actor host) can
|
|
19
|
+
* silently kill discovery ecosystem-wide. This vector is byte-frozen against the
|
|
20
|
+
* proven live `/ap/users/nate` actor shape; a change here that is not intentional
|
|
21
|
+
* is a federation break, not a test to "fix".
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A media resolver that returns fixed absolute CDN URLs (avatar png, banner jpg). */
|
|
25
|
+
const media: ActorMediaResolver = {
|
|
26
|
+
resolveAvatar: (ref) => (ref === 'avatar-file-id' ? 'https://cloud.oxy.so/media/nate-avatar.png' : undefined),
|
|
27
|
+
resolveBanner: (ref) => (ref === 'banner-file-id' ? 'https://cloud.oxy.so/media/nate-banner.jpg' : undefined),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const buildActor = createLocalActorBuilder({
|
|
31
|
+
domain: 'mention.earth',
|
|
32
|
+
urls: createUrlBuilders('mention.earth'),
|
|
33
|
+
media,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const PUBLIC_KEY_PEM =
|
|
37
|
+
'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END PUBLIC KEY-----\n';
|
|
38
|
+
|
|
39
|
+
const PARAMS = {
|
|
40
|
+
username: 'nate',
|
|
41
|
+
displayName: 'Nate',
|
|
42
|
+
bio: 'building the fediverse',
|
|
43
|
+
avatar: 'avatar-file-id',
|
|
44
|
+
profileHeaderImage: 'banner-file-id',
|
|
45
|
+
publicKey: {
|
|
46
|
+
keyId: 'https://mention.earth/ap/users/nate#main-key',
|
|
47
|
+
publicKeyPem: PUBLIC_KEY_PEM,
|
|
48
|
+
},
|
|
49
|
+
createdAt: '2023-01-15T10:30:00.000Z',
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The frozen expected actor, in the EXACT key order the builder emits. `toEqual`
|
|
54
|
+
* checks values; the `JSON.stringify` string check locks the serialization order.
|
|
55
|
+
*/
|
|
56
|
+
const EXPECTED_ACTOR: Record<string, unknown> = {
|
|
57
|
+
id: 'https://mention.earth/ap/users/nate',
|
|
58
|
+
type: 'Person',
|
|
59
|
+
preferredUsername: 'nate',
|
|
60
|
+
name: 'Nate',
|
|
61
|
+
summary: 'building the fediverse',
|
|
62
|
+
url: 'https://mention.earth/@nate',
|
|
63
|
+
inbox: 'https://mention.earth/ap/users/nate/inbox',
|
|
64
|
+
outbox: 'https://mention.earth/ap/users/nate/outbox',
|
|
65
|
+
featured: 'https://mention.earth/ap/users/nate/collections/featured',
|
|
66
|
+
followers: 'https://mention.earth/ap/users/nate/followers',
|
|
67
|
+
following: 'https://mention.earth/ap/users/nate/following',
|
|
68
|
+
endpoints: { sharedInbox: 'https://mention.earth/ap/inbox' },
|
|
69
|
+
discoverable: true,
|
|
70
|
+
manuallyApprovesFollowers: false,
|
|
71
|
+
icon: { type: 'Image', url: 'https://cloud.oxy.so/media/nate-avatar.png', mediaType: 'image/png' },
|
|
72
|
+
image: { type: 'Image', url: 'https://cloud.oxy.so/media/nate-banner.jpg', mediaType: 'image/jpeg' },
|
|
73
|
+
publicKey: {
|
|
74
|
+
id: 'https://mention.earth/ap/users/nate#main-key',
|
|
75
|
+
owner: 'https://mention.earth/ap/users/nate',
|
|
76
|
+
publicKeyPem: PUBLIC_KEY_PEM,
|
|
77
|
+
},
|
|
78
|
+
published: '2023-01-15T10:30:00.000Z',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe('createLocalActorBuilder (golden actor vector)', () => {
|
|
82
|
+
it('emits the byte-identical Person actor for a fixed user', () => {
|
|
83
|
+
const actor = buildActor(PARAMS);
|
|
84
|
+
expect(actor).toEqual(EXPECTED_ACTOR);
|
|
85
|
+
// Byte-identity: the serialized bytes (key order included) must match exactly.
|
|
86
|
+
expect(JSON.stringify(actor)).toBe(JSON.stringify(EXPECTED_ACTOR));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('serves the byte-identical document once wrapped in the route @context', () => {
|
|
90
|
+
const served = { '@context': AP_CONTEXT, ...buildActor(PARAMS) };
|
|
91
|
+
expect(JSON.stringify(served)).toBe(JSON.stringify({ '@context': AP_CONTEXT, ...EXPECTED_ACTOR }));
|
|
92
|
+
// publicKey.id host MUST equal the actor id host (Mastodon rejects a cross-domain key).
|
|
93
|
+
const key = served.publicKey as { id: string };
|
|
94
|
+
expect(new URL(key.id).host).toBe(new URL(served.id as string).host);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('pins the load-bearing @context term declarations', () => {
|
|
98
|
+
expect(AP_CONTEXT[0]).toBe('https://www.w3.org/ns/activitystreams');
|
|
99
|
+
expect(AP_CONTEXT[1]).toBe('https://w3id.org/security/v1');
|
|
100
|
+
expect(AP_CONTEXT[2]).toMatchObject({
|
|
101
|
+
sensitive: 'as:sensitive',
|
|
102
|
+
toot: 'http://joinmastodon.org/ns#',
|
|
103
|
+
votersCount: 'toot:votersCount',
|
|
104
|
+
quote: { '@id': 'https://w3id.org/fep/044f#quote', '@type': '@id' },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('omits icon/image (but keeps the account valid) when media does not resolve to an absolute URL', () => {
|
|
109
|
+
const warnings: string[] = [];
|
|
110
|
+
const strictBuilder = createLocalActorBuilder({
|
|
111
|
+
domain: 'mention.earth',
|
|
112
|
+
urls: createUrlBuilders('mention.earth'),
|
|
113
|
+
media: { resolveAvatar: () => undefined, resolveBanner: () => 'not-an-absolute-url' },
|
|
114
|
+
onWarn: (m) => warnings.push(m),
|
|
115
|
+
});
|
|
116
|
+
const actor = strictBuilder({ ...PARAMS, avatar: null, profileHeaderImage: 'y' });
|
|
117
|
+
expect(actor.icon).toBeUndefined();
|
|
118
|
+
expect(actor.image).toBeUndefined();
|
|
119
|
+
// A non-absolute banner is warned; an absent avatar is not (nothing to resolve).
|
|
120
|
+
expect(warnings).toHaveLength(1);
|
|
121
|
+
expect(warnings[0]).toContain('Omitting actor image');
|
|
122
|
+
expect(JSON.stringify(actor)).not.toContain('"icon"');
|
|
123
|
+
expect(JSON.stringify(actor)).not.toContain('"image"');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The RECOGNITION vocabulary, which is a different question from what we emit:
|
|
129
|
+
* an inbound `Update` carrying a profile is dispatched on it, so a type missing
|
|
130
|
+
* here means that class of account's profile edits are applied to NOTHING, with
|
|
131
|
+
* no error raised anywhere.
|
|
132
|
+
*
|
|
133
|
+
* `Group` and `Organization` are the two that matter and the two most likely to
|
|
134
|
+
* be "tidied" out, since this engine never emits `Group` at all: a Lemmy
|
|
135
|
+
* community IS a `Group`, and a Mention channel is now an `Organization`.
|
|
136
|
+
*/
|
|
137
|
+
describe('AS2 actor recognition vocabulary', () => {
|
|
138
|
+
it('recognizes all five AS2 actor types', () => {
|
|
139
|
+
expect([...AP_ACTOR_TYPES].sort()).toEqual([
|
|
140
|
+
'Application',
|
|
141
|
+
'Group',
|
|
142
|
+
'Organization',
|
|
143
|
+
'Person',
|
|
144
|
+
'Service',
|
|
145
|
+
]);
|
|
146
|
+
for (const type of AP_ACTOR_TYPES) {
|
|
147
|
+
expect(isApActorType(type)).toBe(true);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('separates actors from the content objects sharing the dispatch', () => {
|
|
152
|
+
// The inbound Update handler is `if (Note|Article) … else if (isApActorType)`,
|
|
153
|
+
// so a predicate that answered true for content would route an edited post
|
|
154
|
+
// into an actor refetch.
|
|
155
|
+
expect(isApActorType('Note')).toBe(false);
|
|
156
|
+
expect(isApActorType('Article')).toBe(false);
|
|
157
|
+
expect(isApActorType(undefined)).toBe(false);
|
|
158
|
+
expect(isApActorType('person')).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('covers every type the emitted subset draws from', () => {
|
|
162
|
+
for (const emitted of Object.values(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)) {
|
|
163
|
+
expect(isApActorType(emitted)).toBe(true);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The actor `type` is the ONE field that varies by account kind, and it is a
|
|
170
|
+
* public claim about what an account IS — a `Person` is a human being. These
|
|
171
|
+
* assertions are written so that a mapping which collapsed to a single constant
|
|
172
|
+
* (the easiest way to break this by accident) cannot pass:
|
|
173
|
+
*
|
|
174
|
+
* - the per-kind table names BOTH sides of every branch, so a fixture set made
|
|
175
|
+
* only of channels could not tell "channels are Service" from "everything is
|
|
176
|
+
* Service";
|
|
177
|
+
* - the coverage assertion is driven by contracts' own `ACCOUNT_KINDS`, so a
|
|
178
|
+
* kind added upstream fails here rather than silently inheriting `Person`;
|
|
179
|
+
* - the distinct-value floor fails any constant mapping outright.
|
|
180
|
+
*/
|
|
181
|
+
describe('actor type by Oxy account kind', () => {
|
|
182
|
+
it('maps each kind to the AS2 type announced for it', () => {
|
|
183
|
+
expect(localActorTypeForAccountKind('personal')).toBe('Person');
|
|
184
|
+
expect(localActorTypeForAccountKind('organization')).toBe('Organization');
|
|
185
|
+
expect(localActorTypeForAccountKind('project')).toBe('Organization');
|
|
186
|
+
expect(localActorTypeForAccountKind('bot')).toBe('Service');
|
|
187
|
+
expect(localActorTypeForAccountKind('channel')).toBe('Organization');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('never announces a human-curated channel as automated', () => {
|
|
191
|
+
// `Service` is what Mastodon writes for "this is an automated account"
|
|
192
|
+
// (account.rb:224) and is half of `bot?` (account.rb:90) — the Automated
|
|
193
|
+
// badge, the SimilarProfilesSource exclusion and Lemmy's `bot_account`.
|
|
194
|
+
// A channel is curated by people; only `bot` may claim automation.
|
|
195
|
+
expect(localActorTypeForAccountKind('channel')).not.toBe('Service');
|
|
196
|
+
expect(localActorTypeForAccountKind('bot')).toBe('Service');
|
|
197
|
+
// `Group` is never emitted for anything: Lemmy would reclassify the actor as
|
|
198
|
+
// a community that silently never receives content, and PeerTube rejects a
|
|
199
|
+
// `Group` lacking `attributedTo` outright.
|
|
200
|
+
expect(Object.values(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND)).not.toContain('Group');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('covers every account kind contracts defines, with more than one answer', () => {
|
|
204
|
+
// Coverage: driven by the upstream vocabulary, so a new kind fails here.
|
|
205
|
+
expect(Object.keys(LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND).sort()).toEqual([...ACCOUNT_KINDS].sort());
|
|
206
|
+
// Vacuity floor: a mapping that returned one constant would pass every
|
|
207
|
+
// single-kind assertion above and fail this.
|
|
208
|
+
const answers = ACCOUNT_KINDS.map((kind) => localActorTypeForAccountKind(kind));
|
|
209
|
+
expect(new Set(answers).size).toBe(3);
|
|
210
|
+
// And specifically: the two kinds either side of the channel decision differ.
|
|
211
|
+
expect(localActorTypeForAccountKind('channel')).not.toBe(
|
|
212
|
+
localActorTypeForAccountKind('personal'),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('falls back to Person for an absent, null, or unrecognized kind', () => {
|
|
217
|
+
expect(localActorTypeForAccountKind(undefined)).toBe('Person');
|
|
218
|
+
expect(localActorTypeForAccountKind(null)).toBe('Person');
|
|
219
|
+
// Version skew: an Oxy API that knows a kind this package does not must not
|
|
220
|
+
// produce `type: undefined` — a malformed actor Mastodon negative-caches.
|
|
221
|
+
expect(localActorTypeForAccountKind('venue')).toBe('Person');
|
|
222
|
+
expect(localActorTypeForAccountKind(7)).toBe('Person');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('emits the kind-derived type from the builder, keeping key order frozen', () => {
|
|
226
|
+
// A channel: NOT a person, and the whole point of the change.
|
|
227
|
+
const channel = buildActor({ ...PARAMS, kind: 'channel' });
|
|
228
|
+
expect(channel.type).toBe('Organization');
|
|
229
|
+
// The other side of the branch — without this the assertion above would hold
|
|
230
|
+
// just as well for a builder that hardcoded 'Organization'.
|
|
231
|
+
const personal = buildActor({ ...PARAMS, kind: 'personal' });
|
|
232
|
+
expect(personal.type).toBe('Person');
|
|
233
|
+
expect(buildActor({ ...PARAMS, kind: 'bot' }).type).toBe('Service');
|
|
234
|
+
|
|
235
|
+
// `type` is field 2 of the byte-frozen document: only its VALUE may vary.
|
|
236
|
+
expect(Object.keys(channel)).toEqual(Object.keys(EXPECTED_ACTOR));
|
|
237
|
+
expect(Object.keys(channel)[1]).toBe('type');
|
|
238
|
+
expect(JSON.stringify(channel)).toBe(
|
|
239
|
+
JSON.stringify({ ...EXPECTED_ACTOR, type: 'Organization' }),
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('leaves an actor with no kind byte-identical to the pre-change document', () => {
|
|
244
|
+
// The golden vector's PARAMS carries no `kind` — the regression guard that
|
|
245
|
+
// this change is inert for every ordinary account already federated.
|
|
246
|
+
expect(buildActor(PARAMS).type).toBe('Person');
|
|
247
|
+
expect(JSON.stringify(buildActor(PARAMS))).toBe(JSON.stringify(EXPECTED_ACTOR));
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('createUrlBuilders', () => {
|
|
252
|
+
it('scopes actor() to actorDomain and the rest to domain', () => {
|
|
253
|
+
const urls = createUrlBuilders('mention.earth', 'actors.mention.earth');
|
|
254
|
+
expect(urls.actor('nate')).toBe('https://actors.mention.earth/ap/users/nate');
|
|
255
|
+
expect(urls.inbox('nate')).toBe('https://mention.earth/ap/users/nate/inbox');
|
|
256
|
+
expect(urls.sharedInbox()).toBe('https://mention.earth/ap/inbox');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { createDomainPolicy } from '../apUri';
|
|
2
|
+
import { createActorResolver, type ActorResolverConfig, type FederatedActorRecordBase } from '../node/actorResolver';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The instance domain policy applies to CACHED actors, not just fetched ones.
|
|
6
|
+
*
|
|
7
|
+
* `fetchRemoteActor` has always refused a blocked host before doing network I/O,
|
|
8
|
+
* but `getOrFetchActor` served its cache hit above that check — so for every
|
|
9
|
+
* instance we had already stored an actor for (which is every instance that has
|
|
10
|
+
* ever reached us), adding its domain to the blocklist changed nothing at all.
|
|
11
|
+
* These lock the cached branch closed and prove the fetching branch still works.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface TestActor extends FederatedActorRecordBase {
|
|
15
|
+
uri: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BLOCKED_ACTOR = 'https://spam.example/users/mallory';
|
|
19
|
+
const ALLOWED_ACTOR = 'https://remote.example/users/bob';
|
|
20
|
+
|
|
21
|
+
function makeResolver(
|
|
22
|
+
overrides: {
|
|
23
|
+
cached?: TestActor | null;
|
|
24
|
+
cachedKey?: Pick<TestActor, 'uri' | 'publicKeyPem'> | null;
|
|
25
|
+
blockedHosts?: string[];
|
|
26
|
+
signedFetch?: (url: string) => Promise<Response>;
|
|
27
|
+
} = {},
|
|
28
|
+
) {
|
|
29
|
+
const blockedHosts = overrides.blockedHosts ?? ['spam.example'];
|
|
30
|
+
const domainPolicy = createDomainPolicy({
|
|
31
|
+
domain: 'mention.earth',
|
|
32
|
+
blockedDomains: blockedHosts,
|
|
33
|
+
});
|
|
34
|
+
const findActorByUriCalls: string[] = [];
|
|
35
|
+
const signedFetchCalls: string[] = [];
|
|
36
|
+
|
|
37
|
+
const config: ActorResolverConfig<TestActor> = {
|
|
38
|
+
federationEnabled: true,
|
|
39
|
+
signedFetch: async (url) => {
|
|
40
|
+
signedFetchCalls.push(url);
|
|
41
|
+
if (overrides.signedFetch) return overrides.signedFetch(url);
|
|
42
|
+
// A 404 ends `fetchRemoteActor` without exercising the parse/upsert path;
|
|
43
|
+
// these tests only care about WHETHER the fetch was attempted.
|
|
44
|
+
return new Response(null, { status: 404 });
|
|
45
|
+
},
|
|
46
|
+
fetchWebFinger: async () => null,
|
|
47
|
+
isBlockedDomain: domainPolicy.isBlockedDomain,
|
|
48
|
+
normalizeFederatedAcct: (acct) => acct,
|
|
49
|
+
domainFromAcct: (acct) => acct.split('@')[1],
|
|
50
|
+
firstStringUrl: () => undefined,
|
|
51
|
+
store: {
|
|
52
|
+
findActorByUri: async (uri) => {
|
|
53
|
+
findActorByUriCalls.push(uri);
|
|
54
|
+
return overrides.cached ?? null;
|
|
55
|
+
},
|
|
56
|
+
upsertActor: async () => null,
|
|
57
|
+
findActorByPublicKeyId: async () => overrides.cachedKey ?? null,
|
|
58
|
+
setActorOxyUserId: async () => {},
|
|
59
|
+
tombstoneActor: async () => null,
|
|
60
|
+
},
|
|
61
|
+
identity: {
|
|
62
|
+
resolveExternalUser: async () => null,
|
|
63
|
+
reportActorGone: async () => 'archived',
|
|
64
|
+
},
|
|
65
|
+
text: {
|
|
66
|
+
inlineField: (value) => (typeof value === 'string' ? value : ''),
|
|
67
|
+
inlineDisplayName: (raw) => raw,
|
|
68
|
+
sanitizeFieldValue: (html) => html,
|
|
69
|
+
htmlToPlainText: (html) => html,
|
|
70
|
+
},
|
|
71
|
+
logger: { info: () => {}, warn: () => {} },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return { resolver: createActorResolver(config), findActorByUriCalls, signedFetchCalls };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A response whose body is pulled lazily, so the test can see HOW MUCH of it the
|
|
79
|
+
* reader asked for.
|
|
80
|
+
*
|
|
81
|
+
* `pulls` and not a `cancel()` flag on purpose. The property under test is that
|
|
82
|
+
* an oversized body is not DRAINED — that the reader stops early instead of
|
|
83
|
+
* buffering whatever a hostile instance sends. Whether the source stream's
|
|
84
|
+
* `cancel()` callback fires is a different question, and one this environment
|
|
85
|
+
* cannot answer: under jest, `reader.cancel()` does not propagate to the
|
|
86
|
+
* underlying source, while the same code in plain Node does. Asserting on it
|
|
87
|
+
* would be asserting on the harness.
|
|
88
|
+
*
|
|
89
|
+
* `pulls` is observable either way, and it is the thing that actually bounds
|
|
90
|
+
* the work: a reader that stopped at the cap has pulled a handful of chunks; one
|
|
91
|
+
* that drained has pulled all of them.
|
|
92
|
+
*/
|
|
93
|
+
function streamedResponse(chunks: string[], init?: ResponseInit): { response: Response; pulls: () => number } {
|
|
94
|
+
let pulls = 0;
|
|
95
|
+
const encoder = new TextEncoder();
|
|
96
|
+
const response = new Response(new ReadableStream<Uint8Array>({
|
|
97
|
+
pull(controller) {
|
|
98
|
+
pulls += 1;
|
|
99
|
+
const chunk = chunks.shift();
|
|
100
|
+
if (chunk === undefined) controller.close();
|
|
101
|
+
else controller.enqueue(encoder.encode(chunk));
|
|
102
|
+
},
|
|
103
|
+
}), init);
|
|
104
|
+
return { response, pulls: () => pulls };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('getOrFetchActor — instance domain policy', () => {
|
|
108
|
+
it('refuses a blocked domain whose actor is ALREADY CACHED', async () => {
|
|
109
|
+
const rig = makeResolver({ cached: { uri: BLOCKED_ACTOR, lastFetchedAt: new Date() } });
|
|
110
|
+
await expect(rig.resolver.getOrFetchActor(BLOCKED_ACTOR)).resolves.toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('refuses before reading the actor cache at all', async () => {
|
|
114
|
+
const rig = makeResolver({ cached: { uri: BLOCKED_ACTOR, lastFetchedAt: new Date() } });
|
|
115
|
+
await rig.resolver.getOrFetchActor(BLOCKED_ACTOR);
|
|
116
|
+
expect(rig.findActorByUriCalls).toHaveLength(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Pre-existing behaviour (`fetchRemoteActor` has always refused a blocked host);
|
|
120
|
+
// held here so the uncached branch cannot regress alongside the cached one.
|
|
121
|
+
it('refuses a blocked domain with no cached actor, without any network I/O', async () => {
|
|
122
|
+
const rig = makeResolver({ cached: null });
|
|
123
|
+
await expect(rig.resolver.getOrFetchActor(BLOCKED_ACTOR)).resolves.toBeNull();
|
|
124
|
+
expect(rig.signedFetchCalls).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('refuses a STALE cached row for a blocked domain, and refreshes nothing', async () => {
|
|
128
|
+
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
|
129
|
+
const rig = makeResolver({ cached: { uri: BLOCKED_ACTOR, lastFetchedAt: stale } });
|
|
130
|
+
// Staleness used to pick the background-refresh branch and STILL hand the
|
|
131
|
+
// caller the cached row; the row must now not be returned at all.
|
|
132
|
+
await expect(rig.resolver.getOrFetchActor(BLOCKED_ACTOR)).resolves.toBeNull();
|
|
133
|
+
// Let any detached refresh promise settle before asserting nothing was sent.
|
|
134
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
135
|
+
expect(rig.signedFetchCalls).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('fails closed on an actor URI with no parseable host', async () => {
|
|
139
|
+
const rig = makeResolver({ cached: { uri: 'not-a-uri' } });
|
|
140
|
+
await expect(rig.resolver.getOrFetchActor('not-a-uri')).resolves.toBeNull();
|
|
141
|
+
expect(rig.findActorByUriCalls).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('still returns a cached actor for an allowed domain', async () => {
|
|
145
|
+
const cached: TestActor = { uri: ALLOWED_ACTOR, lastFetchedAt: new Date() };
|
|
146
|
+
const rig = makeResolver({ cached });
|
|
147
|
+
await expect(rig.resolver.getOrFetchActor(ALLOWED_ACTOR)).resolves.toBe(cached);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('still fetches an allowed domain that is not cached', async () => {
|
|
151
|
+
const rig = makeResolver({ cached: null });
|
|
152
|
+
await rig.resolver.getOrFetchActor(ALLOWED_ACTOR);
|
|
153
|
+
expect(rig.signedFetchCalls).toEqual([ALLOWED_ACTOR]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('fetchPublicKey — signature lookups stay honest', () => {
|
|
158
|
+
it('serves a cached key for a blocked domain, so its signature is judged on merit', async () => {
|
|
159
|
+
const rig = makeResolver({ cachedKey: { uri: BLOCKED_ACTOR, publicKeyPem: 'PEM' } });
|
|
160
|
+
await expect(rig.resolver.fetchPublicKey(`${BLOCKED_ACTOR}#main-key`)).resolves.toEqual({
|
|
161
|
+
publicKeyPem: 'PEM',
|
|
162
|
+
actorUri: BLOCKED_ACTOR,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('will not resolve a blocked domain key that would require network I/O', async () => {
|
|
167
|
+
const rig = makeResolver({ cachedKey: null, cached: null });
|
|
168
|
+
await expect(rig.resolver.fetchPublicKey(`${BLOCKED_ACTOR}#main-key`)).resolves.toBeNull();
|
|
169
|
+
expect(rig.signedFetchCalls).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('fetchRemoteActor — bounded remote bodies', () => {
|
|
174
|
+
/**
|
|
175
|
+
* A body far larger than the cap, in chunks a quarter of it. A reader that
|
|
176
|
+
* stops at the cap pulls a handful; one that drains pulls all `count`.
|
|
177
|
+
*/
|
|
178
|
+
function chunked(chunkBytes: number, count: number, init?: ResponseInit) {
|
|
179
|
+
return streamedResponse(Array.from({ length: count }, () => 'x'.repeat(chunkBytes)), init);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('stops reading an actor body once it exceeds the decompressed-size cap', async () => {
|
|
183
|
+
// 24 chunks of an eighth-cap each: 3x the cap on offer. Kept modest on
|
|
184
|
+
// purpose — a body large enough to make the point but not to make an
|
|
185
|
+
// UNBOUNDED reader die of memory pressure, which would fail this test for
|
|
186
|
+
// the wrong reason and hide what it measures.
|
|
187
|
+
const oversized = chunked(128 * 1024, 24);
|
|
188
|
+
const rig = makeResolver({ signedFetch: async () => oversized.response });
|
|
189
|
+
|
|
190
|
+
await expect(rig.resolver.fetchRemoteActor(ALLOWED_ACTOR)).resolves.toBeNull();
|
|
191
|
+
expect(oversized.pulls()).toBeLessThan(15);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('drains a body that stays under the cap — the bound is what stops it, not an early return', async () => {
|
|
195
|
+
// Positive control for the three bounds below: without this, a resolver
|
|
196
|
+
// that had broken into never reading a body at all would satisfy every
|
|
197
|
+
// `pulls()` assertion here and read as correctly bounded.
|
|
198
|
+
const actor = JSON.stringify({
|
|
199
|
+
id: ALLOWED_ACTOR,
|
|
200
|
+
inbox: 'https://remote.example/inbox',
|
|
201
|
+
preferredUsername: 'bob',
|
|
202
|
+
});
|
|
203
|
+
const small = streamedResponse(actor.match(/.{1,16}/g) ?? []);
|
|
204
|
+
const rig = makeResolver({ signedFetch: async () => small.response });
|
|
205
|
+
|
|
206
|
+
await rig.resolver.fetchRemoteActor(ALLOWED_ACTOR);
|
|
207
|
+
// Every chunk, plus the pull that closes the stream.
|
|
208
|
+
expect(small.pulls()).toBeGreaterThan(Math.ceil(actor.length / 16));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('rejects an oversized actor from Content-Length without consuming its stream', async () => {
|
|
212
|
+
let cancelled = false;
|
|
213
|
+
const response = new Response(new ReadableStream<Uint8Array>({
|
|
214
|
+
pull(controller) {
|
|
215
|
+
controller.enqueue(new Uint8Array([123]));
|
|
216
|
+
},
|
|
217
|
+
cancel() {
|
|
218
|
+
cancelled = true;
|
|
219
|
+
},
|
|
220
|
+
}), { headers: { 'content-length': String(1024 * 1024 + 1) } });
|
|
221
|
+
const rig = makeResolver({ signedFetch: async () => response });
|
|
222
|
+
|
|
223
|
+
await expect(rig.resolver.fetchRemoteActor(ALLOWED_ACTOR)).resolves.toBeNull();
|
|
224
|
+
expect(cancelled).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('bounds actor-advertised collection responses and fails the count soft', async () => {
|
|
228
|
+
const actor = JSON.stringify({
|
|
229
|
+
id: ALLOWED_ACTOR,
|
|
230
|
+
inbox: 'https://remote.example/inbox',
|
|
231
|
+
preferredUsername: 'bob',
|
|
232
|
+
followers: 'https://remote.example/followers',
|
|
233
|
+
});
|
|
234
|
+
const oversizedCollection = chunked(8 * 1024, 24);
|
|
235
|
+
const rig = makeResolver({
|
|
236
|
+
signedFetch: async (url) => url === ALLOWED_ACTOR
|
|
237
|
+
? new Response(actor)
|
|
238
|
+
: oversizedCollection.response,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await expect(rig.resolver.fetchRemoteActor(ALLOWED_ACTOR)).resolves.toBeNull();
|
|
242
|
+
expect(oversizedCollection.pulls()).toBeLessThan(15);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('caps an error body before attempting the WebFinger fallback', async () => {
|
|
246
|
+
const oversized = chunked(512, 24, { status: 500 });
|
|
247
|
+
const rig = makeResolver({ signedFetch: async () => oversized.response });
|
|
248
|
+
|
|
249
|
+
await expect(rig.resolver.fetchRemoteActor(ALLOWED_ACTOR)).resolves.toBeNull();
|
|
250
|
+
expect(oversized.pulls()).toBeLessThan(15);
|
|
251
|
+
});
|
|
252
|
+
});
|