@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,268 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Inbound ActivityPub dispatch + the follow-protocol handlers.
|
|
4
|
+
*
|
|
5
|
+
* The engine owns the DISPATCHER (validate the untrusted activity, switch on its
|
|
6
|
+
* type) and the FOLLOW-PROTOCOL verbs — Follow / Accept / Undo(Follow) / Reject —
|
|
7
|
+
* because those are identical across every Oxy app: they bridge a federated follow
|
|
8
|
+
* edge into the Oxy graph (via the identity adapter), record the AP-side follow
|
|
9
|
+
* row (via the store adapter), and send the Accept back (via the delivery
|
|
10
|
+
* service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
|
|
11
|
+
* non-follow Undo) is handed to the app-registered
|
|
12
|
+
* {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
|
|
13
|
+
* post/engagement handlers live. The consent gate + notification side effects are
|
|
14
|
+
* injected so the engine holds no app knowledge.
|
|
15
|
+
*
|
|
16
|
+
* Extracted behaviour-identically from Mention's former `InboxProcessingService`
|
|
17
|
+
* dispatcher + `handleIncomingFollow` / `handleUndo(Follow)` / `handleAccept` /
|
|
18
|
+
* `handleReject`.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.ActorResolutionPendingError = void 0;
|
|
22
|
+
exports.createInboundDispatcher = createInboundDispatcher;
|
|
23
|
+
const urls_1 = require("../urls");
|
|
24
|
+
/**
|
|
25
|
+
* Thrown when a federated follow is about to be bridged but the FOLLOWER actor
|
|
26
|
+
* has not yet resolved to an Oxy user (`oxyUserId` missing) — e.g. Oxy was
|
|
27
|
+
* unreachable when the actor was fetched. A federated follow MUST become a real
|
|
28
|
+
* Oxy edge, never a ghost, so the whole inbound activity is DEFERRED rather than
|
|
29
|
+
* bridged half-way:
|
|
30
|
+
*
|
|
31
|
+
* - in the BullMQ inbox worker, throwing fails the job, which retries with
|
|
32
|
+
* bounded exponential backoff; a later attempt (Oxy reachable) resolves the
|
|
33
|
+
* actor and bridges the follow. A permanently-unresolvable actor exhausts the
|
|
34
|
+
* attempts and the activity is dropped — never a ghost edge.
|
|
35
|
+
* - in the inline (no-Redis) fallback, it surfaces as a 500 from the inbox
|
|
36
|
+
* endpoint, so the remote re-delivers per ActivityPub.
|
|
37
|
+
*/
|
|
38
|
+
class ActorResolutionPendingError extends Error {
|
|
39
|
+
constructor(actorUri, context) {
|
|
40
|
+
super(`Actor ${actorUri} is not yet resolved to an Oxy user${context ? ` (${context})` : ''}; deferring inbound activity`);
|
|
41
|
+
this.name = 'ActorResolutionPendingError';
|
|
42
|
+
this.actorUri = actorUri;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
exports.ActorResolutionPendingError = ActorResolutionPendingError;
|
|
46
|
+
/**
|
|
47
|
+
* The lowercased host of an actor URI, or null when it is not a parseable absolute
|
|
48
|
+
* URL. Callers treat null as blocked: an origin whose host cannot be determined
|
|
49
|
+
* cannot be checked against the domain policy, so it fails closed.
|
|
50
|
+
*/
|
|
51
|
+
function actorUriHost(actorUri) {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(actorUri).hostname.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Read the `object`'s referenced actor/target uri (a string, or an embedded `{ id }`). */
|
|
60
|
+
function objectTargetUri(object) {
|
|
61
|
+
if (typeof object === 'string')
|
|
62
|
+
return object;
|
|
63
|
+
if (object && typeof object === 'object') {
|
|
64
|
+
const id = object.id;
|
|
65
|
+
if (typeof id === 'string')
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Build the inbound-activity dispatcher from an app's adapters + content handlers. */
|
|
71
|
+
function createInboundDispatcher(config) {
|
|
72
|
+
const { logger } = config;
|
|
73
|
+
async function handleIncomingFollow(activity, actorUri) {
|
|
74
|
+
const targetActorUri = objectTargetUri(activity.object);
|
|
75
|
+
if (!targetActorUri)
|
|
76
|
+
return;
|
|
77
|
+
// Extract username from our actor URL
|
|
78
|
+
const match = targetActorUri.match(/\/ap\/users\/([^/]+)$/);
|
|
79
|
+
if (!match)
|
|
80
|
+
return;
|
|
81
|
+
const username = (0, urls_1.normalizeActorUsername)(match[1]);
|
|
82
|
+
// Resolve the Oxy user to get a real user ID
|
|
83
|
+
const user = await config.identity.resolveUserByUsername(username);
|
|
84
|
+
if (!user) {
|
|
85
|
+
logger.warn(`Incoming follow for unknown user ${username} from ${actorUri}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const localUserId = String(user._id || user.id);
|
|
89
|
+
// The target user may have turned fediverse sharing off — drop the Follow
|
|
90
|
+
// silently (no bridge, no Accept, no Reject). A Reject is unverifiable
|
|
91
|
+
// against a 404'd actor and would reveal the account exists, so this must
|
|
92
|
+
// look identical to a Follow sent to an unknown user. Gated here, BEFORE
|
|
93
|
+
// the follower actor is fetched/resolved, so an OFF user never triggers any
|
|
94
|
+
// of the bridge/Accept/notification side effects below.
|
|
95
|
+
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
96
|
+
logger.debug(`[Federation] inbound follow for ${username} dropped — sharing off`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Resolve the follower actor and REQUIRE its Oxy user id: a fediverse
|
|
100
|
+
// follower must become a real Oxy edge, never a ghost. When the actor is
|
|
101
|
+
// missing or not yet resolved to an Oxy user (Oxy was unreachable when it was
|
|
102
|
+
// fetched), throw `ActorResolutionPendingError` so the BullMQ inbox job
|
|
103
|
+
// retries with backoff and bridges the follow on a later attempt.
|
|
104
|
+
const actor = await config.actorResolver.getOrFetchActor(actorUri);
|
|
105
|
+
const followerOxyUserId = actor?.oxyUserId;
|
|
106
|
+
if (!followerOxyUserId) {
|
|
107
|
+
throw new ActorResolutionPendingError(actorUri, `Follow ${String(activity.id)}`);
|
|
108
|
+
}
|
|
109
|
+
// A self-follow (the follower resolves to the same local user) is meaningless
|
|
110
|
+
// in the Oxy graph — skip before touching any state or delivering an Accept.
|
|
111
|
+
if (followerOxyUserId === localUserId) {
|
|
112
|
+
logger.debug(`[Federation] ignoring self-follow from ${actorUri} to ${username}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Create the Oxy follow edge BEFORE sending Accept so a retry never spams
|
|
116
|
+
// Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
|
|
117
|
+
// before the edge was committed could be re-sent on every retry. On failure
|
|
118
|
+
// the bridge throws, failing the job so the whole sequence retries.
|
|
119
|
+
await config.identity.bridgeFollow(followerOxyUserId, localUserId);
|
|
120
|
+
await config.follows.upsertInboundAccepted(localUserId, actorUri, String(activity.id));
|
|
121
|
+
// Send Accept back so the remote server knows the follow succeeded
|
|
122
|
+
await config.delivery.sendAccept(localUserId, username, String(activity.id), actorUri);
|
|
123
|
+
// Fail-soft: the Oxy edge is already committed, so a notification failure must
|
|
124
|
+
// never fail (and thus retry) the follow.
|
|
125
|
+
if (config.onInboundFollowAccepted) {
|
|
126
|
+
await config.onInboundFollowAccepted(localUserId, followerOxyUserId, actorUri);
|
|
127
|
+
}
|
|
128
|
+
logger.info(`Accepted follow from ${actorUri} to ${username}`);
|
|
129
|
+
}
|
|
130
|
+
async function handleUndoFollow(object, actorUri) {
|
|
131
|
+
const targetActorUri = objectTargetUri(object.object);
|
|
132
|
+
const match = targetActorUri?.match(/\/ap\/users\/([^/]+)$/);
|
|
133
|
+
let localUserId;
|
|
134
|
+
if (match) {
|
|
135
|
+
const user = await config.identity.resolveUserByUsername((0, urls_1.normalizeActorUsername)(match[1]));
|
|
136
|
+
if (user)
|
|
137
|
+
localUserId = String(user._id || user.id);
|
|
138
|
+
}
|
|
139
|
+
// Idempotency: locate the follow row FIRST. Absent → this Undo was already
|
|
140
|
+
// processed (a redelivery), so there is nothing to tear down — return.
|
|
141
|
+
const follow = await config.follows.findInboundFollow(actorUri, localUserId);
|
|
142
|
+
if (!follow) {
|
|
143
|
+
logger.debug(`Undo follow from ${actorUri}: no matching row (already processed)`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Remove the Oxy follow edge BEFORE deleting the local row, so a transient
|
|
147
|
+
// bridge failure retries with the row still present. The edge can only exist
|
|
148
|
+
// when the follower actor resolved to an Oxy user; without an `oxyUserId` no
|
|
149
|
+
// edge was ever created, so there is nothing to remove. THROW on transient
|
|
150
|
+
// bridge failure (job retry); the bridge is idempotent.
|
|
151
|
+
const followerOxyUserId = await config.follows.findActorOxyUserId(actorUri);
|
|
152
|
+
if (followerOxyUserId) {
|
|
153
|
+
await config.identity.bridgeUnfollow(followerOxyUserId, follow.localUserId);
|
|
154
|
+
}
|
|
155
|
+
await config.follows.deleteFollowById(follow._id);
|
|
156
|
+
logger.debug(`Undo follow from ${actorUri}`);
|
|
157
|
+
}
|
|
158
|
+
async function handleUndo(activity, actorUri) {
|
|
159
|
+
const object = activity.object;
|
|
160
|
+
if (!object)
|
|
161
|
+
return;
|
|
162
|
+
const objectType = typeof object === 'string' ? null : object.type;
|
|
163
|
+
if (objectType === 'Follow') {
|
|
164
|
+
await handleUndoFollow(object, actorUri);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// Undo(Like) / Undo(Announce) — content teardown. Hand the WHOLE Undo
|
|
168
|
+
// activity to the app (it re-inspects the embedded object type).
|
|
169
|
+
await config.onContentActivity(activity, actorUri);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function handleAccept(activity, actorUri) {
|
|
173
|
+
const object = activity.object;
|
|
174
|
+
if (!object)
|
|
175
|
+
return;
|
|
176
|
+
let updated = false;
|
|
177
|
+
if (typeof object === 'string') {
|
|
178
|
+
// Remote sent Accept with a string reference (the Follow activity ID).
|
|
179
|
+
// Try matching by activityId first, fall back to any pending follow.
|
|
180
|
+
updated = await config.follows.markOutboundAcceptedByActivityId(actorUri, object);
|
|
181
|
+
if (!updated) {
|
|
182
|
+
updated = await config.follows.markOutboundAcceptedAnyPending(actorUri);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (object.type === 'Follow') {
|
|
186
|
+
const followActivityId = object.id;
|
|
187
|
+
updated = typeof followActivityId === 'string' && followActivityId.length > 0
|
|
188
|
+
? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
|
|
189
|
+
: await config.follows.markOutboundAcceptedAnyPending(actorUri);
|
|
190
|
+
}
|
|
191
|
+
if (updated) {
|
|
192
|
+
logger.debug(`Follow accepted by ${actorUri}`);
|
|
193
|
+
// Fire-and-forget: backfill the newly followed actor's recent posts.
|
|
194
|
+
if (config.onOutboundFollowAccepted) {
|
|
195
|
+
await config.onOutboundFollowAccepted(actorUri);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function handleReject(activity, actorUri) {
|
|
200
|
+
const object = activity.object;
|
|
201
|
+
if (!object)
|
|
202
|
+
return;
|
|
203
|
+
const objectType = typeof object === 'string' ? null : object.type;
|
|
204
|
+
if (objectType === 'Follow') {
|
|
205
|
+
const followActivityId = typeof object === 'object' ? object.id : undefined;
|
|
206
|
+
await config.follows.markOutboundRejected(actorUri, typeof followActivityId === 'string' ? followActivityId : undefined);
|
|
207
|
+
logger.debug(`Follow rejected by ${actorUri}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function processInboxActivity(activity, verifiedActorUri) {
|
|
211
|
+
// Instance domain policy, FIRST — before the payload is even parsed.
|
|
212
|
+
//
|
|
213
|
+
// This is the single chokepoint for inbound federation: every transport (the
|
|
214
|
+
// inbox route's inline path, the BullMQ inbox worker replaying a queued job,
|
|
215
|
+
// and any direct connector call) converges here, and every verb — Follow,
|
|
216
|
+
// Accept, Undo, Reject, and the app's content verbs — is dispatched below it.
|
|
217
|
+
// So one check here suspends an instance completely: no posts, no actors, no
|
|
218
|
+
// follows, no boosts, no notifications.
|
|
219
|
+
//
|
|
220
|
+
// It is keyed on `verifiedActorUri`, the origin the HTTP signature actually
|
|
221
|
+
// proved, which is also the identity every handler downstream trusts — so
|
|
222
|
+
// there is no second, weaker identity a hostile payload could be routed under.
|
|
223
|
+
const originHost = actorUriHost(verifiedActorUri);
|
|
224
|
+
if (originHost === null || config.isBlockedDomain(originHost)) {
|
|
225
|
+
logger.warn(`[Federation] dropping inbound activity from blocked origin ${verifiedActorUri} (host=${originHost ?? 'unparseable'})`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Inbound JSON arrives from arbitrary, UNTRUSTED remote servers. Validate the
|
|
229
|
+
// whole activity BEFORE any handler reads it. The validation never throws; a
|
|
230
|
+
// malformed or hostile payload is rejected cleanly here.
|
|
231
|
+
const validation = config.validateActivity(activity);
|
|
232
|
+
if (!validation.ok) {
|
|
233
|
+
const rawType = typeof activity?.type === 'string'
|
|
234
|
+
? activity.type
|
|
235
|
+
: Array.isArray(activity?.type)
|
|
236
|
+
? activity.type.join(',')
|
|
237
|
+
: 'unknown';
|
|
238
|
+
const rawId = typeof activity?.id === 'string' ? activity.id : 'unknown';
|
|
239
|
+
logger.warn(`[Federation] dropping invalid inbound activity from ${verifiedActorUri} (type=${rawType}, id=${rawId}): ${validation.summary}`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
switch (validation.type) {
|
|
243
|
+
case 'Follow':
|
|
244
|
+
await handleIncomingFollow(activity, verifiedActorUri);
|
|
245
|
+
break;
|
|
246
|
+
case 'Undo':
|
|
247
|
+
await handleUndo(activity, verifiedActorUri);
|
|
248
|
+
break;
|
|
249
|
+
case 'Accept':
|
|
250
|
+
await handleAccept(activity, verifiedActorUri);
|
|
251
|
+
break;
|
|
252
|
+
case 'Reject':
|
|
253
|
+
await handleReject(activity, verifiedActorUri);
|
|
254
|
+
break;
|
|
255
|
+
// Content verbs — the app owns these (posts, engagement, actor profile edits).
|
|
256
|
+
case 'Create':
|
|
257
|
+
case 'Delete':
|
|
258
|
+
case 'Like':
|
|
259
|
+
case 'Announce':
|
|
260
|
+
case 'Update':
|
|
261
|
+
await config.onContentActivity(activity, verifiedActorUri);
|
|
262
|
+
break;
|
|
263
|
+
default:
|
|
264
|
+
logger.debug(`Unhandled activity type: ${validation.type}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { processInboxActivity };
|
|
268
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@oxy.so/federation/node` — the runnable Node/Express federation engine.
|
|
4
|
+
*
|
|
5
|
+
* A SEPARATE subpath from the package root so this Node-only code never enters
|
|
6
|
+
* isomorphic bundles that import `@oxy.so/federation`.
|
|
7
|
+
*
|
|
8
|
+
* Phase 2 (HTTP signatures): the signed-fetch transport — a signed ActivityPub
|
|
9
|
+
* GET with per-hop HTTP-signature re-signing, built over an app-injected
|
|
10
|
+
* SSRF-safe single-hop transport. The pure sign/verify crypto it drives lives in
|
|
11
|
+
* the isomorphic `.` entry.
|
|
12
|
+
*
|
|
13
|
+
* Phase 3 (actor model + resolution): the identity bridge + remote-actor resolver.
|
|
14
|
+
*
|
|
15
|
+
* Phase 4 (delivery + follow lifecycle + routers + inbound dispatch): the outbound
|
|
16
|
+
* delivery transport + follow protocol, the inbound dispatcher (Follow/Accept/
|
|
17
|
+
* Undo(Follow)/Reject, delegating content verbs to the app), and the webfinger +
|
|
18
|
+
* actor + inbox + follow-graph Express routers.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.createActorRouter = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createSignedFetch = void 0;
|
|
22
|
+
var signedFetch_1 = require("./signedFetch");
|
|
23
|
+
Object.defineProperty(exports, "createSignedFetch", { enumerable: true, get: function () { return signedFetch_1.createSignedFetch; } });
|
|
24
|
+
/**
|
|
25
|
+
* The actor↔Oxy-user identity bridge — the default implementation of the
|
|
26
|
+
* `PUT /users/resolve` + actor-gone archive/delete seam over an injected
|
|
27
|
+
* service-request transport.
|
|
28
|
+
*/
|
|
29
|
+
var identityBridge_1 = require("./identityBridge");
|
|
30
|
+
Object.defineProperty(exports, "createIdentityBridge", { enumerable: true, get: function () { return identityBridge_1.createIdentityBridge; } });
|
|
31
|
+
/**
|
|
32
|
+
* Remote-actor resolution/caching/refresh (webfinger, signed actor fetch,
|
|
33
|
+
* 410-Gone tombstone) over a bring-your-own-store adapter, the identity bridge,
|
|
34
|
+
* and injected transports + text normalization.
|
|
35
|
+
*/
|
|
36
|
+
var actorResolver_1 = require("./actorResolver");
|
|
37
|
+
Object.defineProperty(exports, "createActorResolver", { enumerable: true, get: function () { return actorResolver_1.createActorResolver; } });
|
|
38
|
+
Object.defineProperty(exports, "ActorResolver", { enumerable: true, get: function () { return actorResolver_1.ActorResolver; } });
|
|
39
|
+
/**
|
|
40
|
+
* Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
|
|
41
|
+
* Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
|
|
42
|
+
* custody, an SSRF-safe delivery transport, and bring-your-own-store adapters.
|
|
43
|
+
*/
|
|
44
|
+
var delivery_1 = require("./delivery");
|
|
45
|
+
Object.defineProperty(exports, "createDeliveryService", { enumerable: true, get: function () { return delivery_1.createDeliveryService; } });
|
|
46
|
+
/**
|
|
47
|
+
* Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
|
|
48
|
+
* follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
|
|
49
|
+
* identity + store adapters, and the `onContentActivity` seam every content verb
|
|
50
|
+
* (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
|
|
51
|
+
*/
|
|
52
|
+
var inboundDispatch_1 = require("./inboundDispatch");
|
|
53
|
+
Object.defineProperty(exports, "createInboundDispatcher", { enumerable: true, get: function () { return inboundDispatch_1.createInboundDispatcher; } });
|
|
54
|
+
Object.defineProperty(exports, "ActorResolutionPendingError", { enumerable: true, get: function () { return inboundDispatch_1.ActorResolutionPendingError; } });
|
|
55
|
+
/** The WebFinger + host-meta discovery router (domain-parameterized, consent-gated). */
|
|
56
|
+
var webfingerRouter_1 = require("./webfingerRouter");
|
|
57
|
+
Object.defineProperty(exports, "createWebfingerRouter", { enumerable: true, get: function () { return webfingerRouter_1.createWebfingerRouter; } });
|
|
58
|
+
/**
|
|
59
|
+
* The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
|
|
60
|
+
* `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
|
|
61
|
+
*/
|
|
62
|
+
var actorRouter_1 = require("./actorRouter");
|
|
63
|
+
Object.defineProperty(exports, "createActorRouter", { enumerable: true, get: function () { return actorRouter_1.createActorRouter; } });
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `signedFetch` — a signed ActivityPub GET with per-hop HTTP-signature
|
|
4
|
+
* re-signing, built over an injected SSRF-safe single-hop transport.
|
|
5
|
+
*
|
|
6
|
+
* WHY A FACTORY OVER AN INJECTED TRANSPORT (not core `safeFetch` directly)
|
|
7
|
+
* -----------------------------------------------------------------------
|
|
8
|
+
* An HTTP signature is bound to the `(request-target)`/`host` of ONE specific
|
|
9
|
+
* URL, so on a redirect the signature MUST be recomputed for the new target.
|
|
10
|
+
* `@oxy.so/core/server`'s `safeFetch` follows redirects internally and re-sends
|
|
11
|
+
* the ORIGINAL headers on each hop (it never re-signs, and it destroys redirect
|
|
12
|
+
* bodies), so it cannot back per-hop re-signing. Instead — mirroring how
|
|
13
|
+
* `@oxy.so/protocol/node` injects its `NodeFetch` adapter over `safeFetch` — this
|
|
14
|
+
* factory takes a single-hop transport that validates + IP-pins ONE request and
|
|
15
|
+
* returns the response WITHOUT following redirects. The engine owns the
|
|
16
|
+
* federation policy (signing, the bounded redirect loop that re-signs each hop,
|
|
17
|
+
* the unsigned 5xx fallback); the app supplies the SSRF transport (Mention adapts
|
|
18
|
+
* its `@oxy.so/core/server`-based single-hop fetch), keeping the SSRF/DNS-pin
|
|
19
|
+
* policy in ONE place.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.createSignedFetch = createSignedFetch;
|
|
23
|
+
const httpSignature_1 = require("../httpSignature");
|
|
24
|
+
/** Total time budget for a single signed hop (connect + response headers). */
|
|
25
|
+
const SIGNED_FETCH_TIMEOUT_MS = 10000;
|
|
26
|
+
/** Bounded redirect budget for signed AP GETs; each hop is re-validated and re-signed. */
|
|
27
|
+
const SIGNED_FETCH_MAX_REDIRECTS = 3;
|
|
28
|
+
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
29
|
+
function requestInitHeaders(init) {
|
|
30
|
+
if (!init.headers)
|
|
31
|
+
return {};
|
|
32
|
+
if (init.headers instanceof Headers) {
|
|
33
|
+
// `Headers.forEach` is typed on the base `DOM` lib, whereas
|
|
34
|
+
// `Headers.entries()` requires `DOM.Iterable` — which this package's build
|
|
35
|
+
// tsconfig deliberately omits (isomorphic node/web split). `forEach` keeps
|
|
36
|
+
// the flatten build-safe under every lib config (Docker + local).
|
|
37
|
+
const flattened = {};
|
|
38
|
+
init.headers.forEach((value, key) => {
|
|
39
|
+
flattened[key] = value;
|
|
40
|
+
});
|
|
41
|
+
return flattened;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(init.headers))
|
|
44
|
+
return Object.fromEntries(init.headers);
|
|
45
|
+
return init.headers;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build a `signedFetch(url, accept, init?)`:
|
|
49
|
+
*
|
|
50
|
+
* Signs a GET request using the instance actor key (via the injected signer) and
|
|
51
|
+
* performs it under the SSRF-safe contract (the injected single-hop transport
|
|
52
|
+
* validates the URL AND pins the TCP connection to the validated IP).
|
|
53
|
+
*
|
|
54
|
+
* Redirects are followed manually (bounded by {@link SIGNED_FETCH_MAX_REDIRECTS}),
|
|
55
|
+
* re-validating AND re-signing each hop — an HTTP signature is bound to the
|
|
56
|
+
* `(request-target)`/`host` of a specific URL. When the caller passes
|
|
57
|
+
* `init.redirect === 'manual'`, the redirect `Response` is returned directly so
|
|
58
|
+
* the caller can apply its own stricter redirect policy.
|
|
59
|
+
*
|
|
60
|
+
* Signed for servers that enforce authorized fetch (e.g. Threads). On a 5xx the
|
|
61
|
+
* request is retried unsigned (same SSRF-safe path) as a fallback for public
|
|
62
|
+
* resources.
|
|
63
|
+
*/
|
|
64
|
+
function createSignedFetch(config) {
|
|
65
|
+
return async function signedFetch(url, accept, init = {}) {
|
|
66
|
+
const acceptHeader = `${accept}, application/ld+json; profile="https://www.w3.org/ns/activitystreams"`;
|
|
67
|
+
const keyId = await config.getInstanceKeyId();
|
|
68
|
+
const extraHeaders = requestInitHeaders(init);
|
|
69
|
+
const manualRedirect = init.redirect === 'manual';
|
|
70
|
+
const fetchOnce = async (targetUrl, signed) => {
|
|
71
|
+
const sigHeaders = signed ? await (0, httpSignature_1.signRequest)(config.sign, keyId, 'GET', targetUrl) : {};
|
|
72
|
+
return config.fetchSingleHop(targetUrl, {
|
|
73
|
+
headers: {
|
|
74
|
+
Accept: acceptHeader,
|
|
75
|
+
'User-Agent': config.userAgent,
|
|
76
|
+
...sigHeaders,
|
|
77
|
+
...extraHeaders,
|
|
78
|
+
},
|
|
79
|
+
signal: init.signal ?? AbortSignal.timeout(SIGNED_FETCH_TIMEOUT_MS),
|
|
80
|
+
headersTimeoutMs: SIGNED_FETCH_TIMEOUT_MS,
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
const fetchFollowingRedirects = async (initialUrl, signed) => {
|
|
84
|
+
let currentUrl = initialUrl;
|
|
85
|
+
for (let hop = 0; hop <= SIGNED_FETCH_MAX_REDIRECTS; hop++) {
|
|
86
|
+
const res = await fetchOnce(currentUrl, signed);
|
|
87
|
+
if (!REDIRECT_STATUS_CODES.has(res.status)) {
|
|
88
|
+
return { res, finalUrl: currentUrl };
|
|
89
|
+
}
|
|
90
|
+
// The caller asked to handle redirects itself (stricter per-hop policy).
|
|
91
|
+
if (manualRedirect) {
|
|
92
|
+
return { res, finalUrl: currentUrl };
|
|
93
|
+
}
|
|
94
|
+
const location = res.headers.get('location');
|
|
95
|
+
if (hop === SIGNED_FETCH_MAX_REDIRECTS || !location) {
|
|
96
|
+
return { res, finalUrl: currentUrl };
|
|
97
|
+
}
|
|
98
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
99
|
+
}
|
|
100
|
+
throw new Error('redirect loop exhausted');
|
|
101
|
+
};
|
|
102
|
+
const { res, finalUrl } = await fetchFollowingRedirects(url, true);
|
|
103
|
+
// If the remote server returns a 5xx (e.g. it can't resolve our keyId to
|
|
104
|
+
// verify the signature), retry without the signature as a fallback for public
|
|
105
|
+
// resources. Retry from the post-redirect URL so we don't restart a chain that
|
|
106
|
+
// already landed on the failing hop.
|
|
107
|
+
if (res.status >= 500) {
|
|
108
|
+
config.logger?.info(`[FedSync] signedFetch got ${res.status} for ${finalUrl}, retrying unsigned`);
|
|
109
|
+
return fetchFollowingRedirects(finalUrl, false).then(({ res: unsignedRes }) => unsignedRes);
|
|
110
|
+
}
|
|
111
|
+
// A 401/403 on a signed request means the remote rejected OUR signature (e.g.
|
|
112
|
+
// it could not resolve/verify our keyId, or our instance key pair is
|
|
113
|
+
// missing/invalid because the service token could not be acquired). Without a
|
|
114
|
+
// log this silently yields zero results — surface it so the failure mode is
|
|
115
|
+
// observable in production. The caller still receives the response and decides
|
|
116
|
+
// how to proceed; we do not change control flow here.
|
|
117
|
+
if (res.status === 401 || res.status === 403) {
|
|
118
|
+
config.logger?.warn(`[FedSync] signedFetch got ${res.status} ${res.statusText} for ${url} — remote rejected our HTTP signature (check instance key pair / service token); returning the failed response so no posts are imported from this source`);
|
|
119
|
+
}
|
|
120
|
+
return res;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The WebFinger + host-meta discovery router.
|
|
4
|
+
*
|
|
5
|
+
* `/.well-known/webfinger` resolves `acct:<user>@<domain>` to the local actor URL;
|
|
6
|
+
* `/.well-known/host-meta(.json)` advertises the WebFinger LRDD template. Both are
|
|
7
|
+
* domain-parameterized (each app answers for its OWN `domain`) and both enforce
|
|
8
|
+
* the fediverse-sharing consent gate — a disabled/unknown user 404s
|
|
9
|
+
* indistinguishably. The JRD cache (Mention: Redis) is injected so the caching
|
|
10
|
+
* strategy stays app-side; the response bytes + the 404-when-off semantics live
|
|
11
|
+
* here so every Oxy app discovers identically.
|
|
12
|
+
*
|
|
13
|
+
* Extracted behaviour-identically from Mention's `wellKnown.routes.ts`.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.createWebfingerRouter = createWebfingerRouter;
|
|
17
|
+
const express_1 = require("express");
|
|
18
|
+
const apUri_1 = require("../apUri");
|
|
19
|
+
const urls_1 = require("../urls");
|
|
20
|
+
/** 1 hour, in seconds — the WebFinger JRD cache TTL + response `max-age`. */
|
|
21
|
+
const WEBFINGER_CACHE_TTL = 3600;
|
|
22
|
+
/** 24h — host-meta is effectively static. */
|
|
23
|
+
const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
|
|
24
|
+
/** Build the WebFinger + host-meta discovery router for an app's domain. */
|
|
25
|
+
function createWebfingerRouter(config) {
|
|
26
|
+
const router = (0, express_1.Router)();
|
|
27
|
+
const { domain } = config;
|
|
28
|
+
const webfingerTemplate = `https://${domain}/.well-known/webfinger?resource={uri}`;
|
|
29
|
+
router.get('/webfinger', async (req, res) => {
|
|
30
|
+
if (!config.federationEnabled) {
|
|
31
|
+
return res.status(404).json({ error: 'Federation is disabled' });
|
|
32
|
+
}
|
|
33
|
+
const resource = typeof req.query.resource === 'string' ? req.query.resource : undefined;
|
|
34
|
+
if (!resource || !resource.startsWith('acct:')) {
|
|
35
|
+
return res.status(400).json({ error: 'Resource must start with acct:' });
|
|
36
|
+
}
|
|
37
|
+
const acct = resource.replace('acct:', '');
|
|
38
|
+
const atIndex = acct.indexOf('@');
|
|
39
|
+
if (atIndex === -1) {
|
|
40
|
+
return res.status(400).json({ error: 'Invalid acct format' });
|
|
41
|
+
}
|
|
42
|
+
const username = (0, urls_1.normalizeActorUsername)(acct.substring(0, atIndex));
|
|
43
|
+
const acctDomain = acct.substring(atIndex + 1).trim();
|
|
44
|
+
if (!(0, apUri_1.isSameFederationHost)(acctDomain, domain)) {
|
|
45
|
+
return res.status(404).json({ error: 'Unknown domain' });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
// The instance actor is NOT an Oxy user: it has no profile, no consent
|
|
49
|
+
// record, and `resolveUser` will never find it — so it must be answered
|
|
50
|
+
// here, ahead of both the resolve and the sharing-consent gate, exactly as
|
|
51
|
+
// the actor route answers ahead of them. Without this branch the server
|
|
52
|
+
// actor is served as an actor but is not WebFinger-resolvable, and every
|
|
53
|
+
// secure-mode instance then refuses our signed GETs: Mastodon's
|
|
54
|
+
// `FetchRemoteKeyService#find_actor` calls `FetchRemoteActorService`
|
|
55
|
+
// WITHOUT `only_key:`, which runs `check_webfinger!` unconditionally, so a
|
|
56
|
+
// 404 here raises `Webfinger::Error` and the signed fetch 401s.
|
|
57
|
+
//
|
|
58
|
+
// Deliberately NOT cached: the document is static (no I/O to amortize) and
|
|
59
|
+
// routing it through the app's JRD cache would let one stale or evicted
|
|
60
|
+
// entry make the server actor undiscoverable for a full TTL — which is the
|
|
61
|
+
// outage this branch exists to prevent.
|
|
62
|
+
if (username === urls_1.INSTANCE_ACTOR_USERNAME) {
|
|
63
|
+
const instanceJrd = {
|
|
64
|
+
subject: `acct:${urls_1.INSTANCE_ACTOR_USERNAME}@${domain}`,
|
|
65
|
+
links: [
|
|
66
|
+
{
|
|
67
|
+
rel: 'self',
|
|
68
|
+
type: 'application/activity+json',
|
|
69
|
+
// The SAME builder call the actor route uses for the actor `id`.
|
|
70
|
+
// Mastodon compares `webfinger.self_link_href` against the actor
|
|
71
|
+
// uri and rejects a mismatch, so these must not be built twice.
|
|
72
|
+
href: config.urls.actor(urls_1.INSTANCE_ACTOR_USERNAME),
|
|
73
|
+
},
|
|
74
|
+
// No `profile-page` rel: the server actor has no human-facing page
|
|
75
|
+
// (`/@instance` is not a profile), and the file's existing policy is
|
|
76
|
+
// that a dangling link is worse than an absent one.
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
80
|
+
res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
|
|
81
|
+
return res.json(instanceJrd);
|
|
82
|
+
}
|
|
83
|
+
// Check the JRD cache first.
|
|
84
|
+
const cached = await config.cache.get(username);
|
|
85
|
+
if (cached) {
|
|
86
|
+
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
87
|
+
res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
|
|
88
|
+
return res.json(cached);
|
|
89
|
+
}
|
|
90
|
+
const user = await config.resolveUser(username);
|
|
91
|
+
if (!user)
|
|
92
|
+
return res.status(404).json({ error: 'User not found' });
|
|
93
|
+
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
94
|
+
// body, no separate error code. UNLIKE the other user-scoped surfaces,
|
|
95
|
+
// webfinger does a SECOND, uncached consent read here rather than reusing
|
|
96
|
+
// the already-resolved `user`: this response is ALSO cached for a full hour
|
|
97
|
+
// below, so a stale-DTO false positive would lock the actor (un)discoverable
|
|
98
|
+
// for up to an hour. An Oxy OUTAGE ('unavailable') on that fresh read falls
|
|
99
|
+
// back to the already-resolved `user` instead of 404ing, so a transient
|
|
100
|
+
// hiccup never makes a real account momentarily undiscoverable.
|
|
101
|
+
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
102
|
+
if (sharingState === 'disabled' || sharingState === 'unknown-user') {
|
|
103
|
+
return res.status(404).json({ error: 'User not found' });
|
|
104
|
+
}
|
|
105
|
+
if (sharingState === 'unavailable' && !config.consent.isSharingEnabledFromUser(user)) {
|
|
106
|
+
return res.status(404).json({ error: 'User not found' });
|
|
107
|
+
}
|
|
108
|
+
const response = {
|
|
109
|
+
subject: `acct:${username}@${domain}`,
|
|
110
|
+
links: [
|
|
111
|
+
{
|
|
112
|
+
rel: 'self',
|
|
113
|
+
type: 'application/activity+json',
|
|
114
|
+
href: config.urls.actor(username),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
rel: 'http://webfinger.net/rel/profile-page',
|
|
118
|
+
type: 'text/html',
|
|
119
|
+
href: `https://${domain}/@${username}`,
|
|
120
|
+
},
|
|
121
|
+
// NOTE: the `http://ostatus.org/schema/1.0/subscribe` (remote-follow) rel
|
|
122
|
+
// is intentionally omitted — there is no authorize-interaction endpoint
|
|
123
|
+
// to point it at, and a dangling template would be worse than its absence.
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
config.cache.set(username, response);
|
|
127
|
+
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
128
|
+
res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
|
|
129
|
+
return res.json(response);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
config.logger.error('WebFinger error:', err);
|
|
133
|
+
return res.status(500).json({ error: 'Internal server error' });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
router.get('/host-meta', (_req, res) => {
|
|
137
|
+
if (!config.federationEnabled) {
|
|
138
|
+
return res.status(404).json({ error: 'Federation is disabled' });
|
|
139
|
+
}
|
|
140
|
+
const xrd = `<?xml version="1.0" encoding="UTF-8"?>
|
|
141
|
+
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
|
142
|
+
<Link rel="lrdd" type="application/jrd+json" template="${webfingerTemplate}"/>
|
|
143
|
+
</XRD>
|
|
144
|
+
`;
|
|
145
|
+
res.set('Content-Type', 'application/xrd+xml; charset=utf-8');
|
|
146
|
+
res.set('Cache-Control', HOST_META_CACHE_CONTROL);
|
|
147
|
+
return res.send(xrd);
|
|
148
|
+
});
|
|
149
|
+
router.get('/host-meta.json', (_req, res) => {
|
|
150
|
+
if (!config.federationEnabled) {
|
|
151
|
+
return res.status(404).json({ error: 'Federation is disabled' });
|
|
152
|
+
}
|
|
153
|
+
res.set('Content-Type', 'application/jrd+json; charset=utf-8');
|
|
154
|
+
res.set('Cache-Control', HOST_META_CACHE_CONTROL);
|
|
155
|
+
return res.json({
|
|
156
|
+
links: [
|
|
157
|
+
{
|
|
158
|
+
rel: 'lrdd',
|
|
159
|
+
type: 'application/jrd+json',
|
|
160
|
+
template: webfingerTemplate,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return router;
|
|
166
|
+
}
|