@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,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
|
|
3
|
+
* Accept(Follow)) and the `Update(Person)` actor rebroadcast.
|
|
4
|
+
*
|
|
5
|
+
* The delivery TRANSPORT (sign → SSRF-safe POST → BullMQ / durable fallback queue),
|
|
6
|
+
* the shared-inbox dedup fan-out, and the follow-protocol activity shapes are the
|
|
7
|
+
* SAME across every Oxy app, so they live here — behaviour-identical to Mention's
|
|
8
|
+
* former `FollowService` delivery half. Everything app-specific is injected:
|
|
9
|
+
*
|
|
10
|
+
* - private-key CUSTODY stays behind the {@link DeliveryKeys} adapter (Mention:
|
|
11
|
+
* oxy-api `/federation/sign` + `/federation/public-key`); the key never enters
|
|
12
|
+
* this package,
|
|
13
|
+
* - the SSRF-safe single-hop POST + the BullMQ enqueue + the durable
|
|
14
|
+
* fallback are the {@link DeliveryTransport}, so the delivery policy stays in
|
|
15
|
+
* one place (Mention's `fetchUpstreamSingleHop` + `FederationDeliveryQueue`),
|
|
16
|
+
* - the AP-specific `FederatedActor` / `FederatedFollow` rows stay in the app DB
|
|
17
|
+
* behind the {@link DeliveryActorStore} / {@link DeliveryFollowStore} adapters
|
|
18
|
+
* ("bring your own store" — no data move),
|
|
19
|
+
* - the actor cache refresh (for a follow whose target inbox is not yet known),
|
|
20
|
+
* the consent gate, the actor-profile resolver, the banner, and the local-actor
|
|
21
|
+
* builder are all injected.
|
|
22
|
+
*
|
|
23
|
+
* The CONTENT federate methods (build the Note / boost / like) STAY in the app and
|
|
24
|
+
* call `deliverToFollowers` / `deliverActivity` / `queueDelivery` here.
|
|
25
|
+
*/
|
|
26
|
+
import { AP_CONTEXT } from '../apContext.js';
|
|
27
|
+
import { signRequest } from '../httpSignature.js';
|
|
28
|
+
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
29
|
+
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
30
|
+
/** How many bytes of a failed-delivery response body are read for the debug log. */
|
|
31
|
+
const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
|
|
32
|
+
/** The ActivityStreams public collection — the `to` addressee of a public activity. */
|
|
33
|
+
const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
34
|
+
/** Read a bounded prefix of a failed-delivery response body for the debug log. */
|
|
35
|
+
async function readResponsePreview(response) {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let totalBytes = 0;
|
|
38
|
+
try {
|
|
39
|
+
for await (const chunk of response) {
|
|
40
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
41
|
+
totalBytes += buffer.byteLength;
|
|
42
|
+
chunks.push(buffer);
|
|
43
|
+
if (totalBytes >= DELIVERY_RESPONSE_PREVIEW_MAX_BYTES)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
response.destroy();
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
|
|
54
|
+
}
|
|
55
|
+
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
56
|
+
export function createDeliveryService(config) {
|
|
57
|
+
const { logger, urls } = config;
|
|
58
|
+
/** Lowercased host of an absolute URL, or null when unparseable (fails closed). */
|
|
59
|
+
function urlHost(url) {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(url).hostname.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True when `url`'s host is missing or on the blocked-domain policy. */
|
|
68
|
+
function isBlockedUrl(url) {
|
|
69
|
+
const host = urlHost(url);
|
|
70
|
+
return host === null || config.isBlockedDomain(host);
|
|
71
|
+
}
|
|
72
|
+
async function deliverActivity(activity, targetInbox, senderOxyUserId, senderUsername) {
|
|
73
|
+
if (isBlockedUrl(targetInbox)) {
|
|
74
|
+
logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const { keyId } = await config.keys.getPublicKey(senderUsername);
|
|
79
|
+
const body = JSON.stringify(activity);
|
|
80
|
+
const sigHeaders = await signRequest(config.keys.sign, keyId, 'POST', targetInbox, body);
|
|
81
|
+
const allHeaders = {
|
|
82
|
+
'Content-Type': config.apContentType,
|
|
83
|
+
'Content-Length': String(Buffer.byteLength(body, 'utf-8')),
|
|
84
|
+
'User-Agent': config.userAgent,
|
|
85
|
+
Accept: config.apContentType,
|
|
86
|
+
...sigHeaders,
|
|
87
|
+
};
|
|
88
|
+
logger.debug(`[FedDeliver] POST ${targetInbox} body=${body} sig-headers=${sigHeaders.Signature?.match(/headers="([^"]+)"/)?.[1]}`);
|
|
89
|
+
const { response, status } = await config.deliverSingleHop(targetInbox, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: allHeaders,
|
|
92
|
+
body,
|
|
93
|
+
signal: AbortSignal.timeout(DELIVER_ACTIVITY_TIMEOUT_MS),
|
|
94
|
+
headersTimeoutMs: DELIVER_ACTIVITY_TIMEOUT_MS,
|
|
95
|
+
});
|
|
96
|
+
if ((status >= 200 && status < 300) || status === 202) {
|
|
97
|
+
response.destroy();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
const responseBody = await readResponsePreview(response);
|
|
101
|
+
logger.debug(`Activity delivery failed to ${targetInbox}: ${status} body=${responseBody.slice(0, 500)}`);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.debug(`Activity delivery error to ${targetInbox}:`, err);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function queueDelivery(activity, targetInbox, senderOxyUserId) {
|
|
110
|
+
if (isBlockedUrl(targetInbox)) {
|
|
111
|
+
logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Defense-in-depth: never enqueue a durable delivery to an unsafe inbox URL.
|
|
115
|
+
// The per-send POST is already SSRF-pinned, but a blocked URL would otherwise
|
|
116
|
+
// sit in the queue and be retried forever.
|
|
117
|
+
const guard = await config.assertSafeInboxUrl(targetInbox);
|
|
118
|
+
if (!guard.ok) {
|
|
119
|
+
logger.warn(`[FedDeliver] not queueing unsafe inbox URL ${targetInbox}: ${guard.reason}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const enqueued = await config.transport
|
|
123
|
+
.enqueueDelivery({ activityJson: activity, targetInbox, senderOxyUserId })
|
|
124
|
+
.catch((err) => {
|
|
125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
126
|
+
logger.warn(`[FedDeliver] enqueue failed for ${targetInbox}, falling back to the durable queue: ${message}`);
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
if (enqueued)
|
|
130
|
+
return;
|
|
131
|
+
await config.transport.fallbackQueue.create({
|
|
132
|
+
activityJson: activity,
|
|
133
|
+
targetInbox,
|
|
134
|
+
senderOxyUserId,
|
|
135
|
+
nextAttemptAt: new Date(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async function resolveActorInbox(actorUri) {
|
|
139
|
+
if (!actorUri)
|
|
140
|
+
return undefined;
|
|
141
|
+
const actor = await config.store.findActorByUri(actorUri);
|
|
142
|
+
if (!actor)
|
|
143
|
+
return undefined;
|
|
144
|
+
return actor.sharedInboxUrl ?? actor.inboxUrl ?? undefined;
|
|
145
|
+
}
|
|
146
|
+
async function deliverToFollowers(activity, senderOxyUserId, senderUsername, options = {}) {
|
|
147
|
+
const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
|
|
148
|
+
const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
|
|
149
|
+
// Group by shared inbox to avoid duplicate deliveries. Follower inboxes
|
|
150
|
+
// first, then the explicit targets — the shared `seen` set dedupes an
|
|
151
|
+
// explicit inbox that an instance already receives as a follower.
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
const inboxes = [];
|
|
154
|
+
for (const actor of actors) {
|
|
155
|
+
const inbox = actor.sharedInboxUrl || actor.inboxUrl;
|
|
156
|
+
if (inbox && !seen.has(inbox)) {
|
|
157
|
+
seen.add(inbox);
|
|
158
|
+
inboxes.push(inbox);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const inbox of options.extraInboxes ?? []) {
|
|
162
|
+
if (inbox && !seen.has(inbox)) {
|
|
163
|
+
seen.add(inbox);
|
|
164
|
+
inboxes.push(inbox);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (inboxes.length === 0)
|
|
168
|
+
return;
|
|
169
|
+
// Durable path: enqueue one BullMQ delivery per shared inbox (deduped per
|
|
170
|
+
// inbox + activity id). When the queue is unavailable fall back to a single
|
|
171
|
+
// batch insert into the durable queue for the inboxes that were not enqueued.
|
|
172
|
+
const now = new Date();
|
|
173
|
+
const durableFallback = [];
|
|
174
|
+
for (const inbox of inboxes) {
|
|
175
|
+
if (isBlockedUrl(inbox)) {
|
|
176
|
+
logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${inbox}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const guard = await config.assertSafeInboxUrl(inbox);
|
|
180
|
+
if (!guard.ok) {
|
|
181
|
+
logger.warn(`[FedDeliver] not queueing unsafe inbox URL ${inbox}: ${guard.reason}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const enqueued = await config.transport
|
|
185
|
+
.enqueueDelivery({ activityJson: activity, targetInbox: inbox, senderOxyUserId })
|
|
186
|
+
.catch((err) => {
|
|
187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
188
|
+
logger.warn(`[FedDeliver] follower enqueue failed for ${inbox}, falling back to the durable queue: ${message}`);
|
|
189
|
+
return false;
|
|
190
|
+
});
|
|
191
|
+
if (!enqueued) {
|
|
192
|
+
durableFallback.push({ activityJson: activity, targetInbox: inbox, senderOxyUserId, nextAttemptAt: now });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (durableFallback.length > 0) {
|
|
196
|
+
await config.transport.fallbackQueue.insertMany(durableFallback);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resolve the target actor's inbox in the background and queue the Follow
|
|
201
|
+
* activity for delivery once known. Fire-and-forget: returns synchronously and
|
|
202
|
+
* never blocks the caller on remote I/O.
|
|
203
|
+
*/
|
|
204
|
+
function queueFollowOnceActorKnown(activity, canonicalUri, localOxyUserId, remoteActorUri) {
|
|
205
|
+
void (async () => {
|
|
206
|
+
try {
|
|
207
|
+
let actor = await config.store.findActorByUri(canonicalUri);
|
|
208
|
+
if (!actor?.inboxUrl) {
|
|
209
|
+
actor = await config.actorRefresh.fetchRemoteActor(remoteActorUri);
|
|
210
|
+
}
|
|
211
|
+
const inbox = actor?.sharedInboxUrl ?? actor?.inboxUrl;
|
|
212
|
+
if (inbox && !isBlockedUrl(inbox) && !isBlockedUrl(remoteActorUri)) {
|
|
213
|
+
await queueDelivery(activity, inbox, localOxyUserId);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
logger.warn(`[FedSync] could not resolve inbox to deliver Follow to ${remoteActorUri}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
221
|
+
logger.warn(`[FedSync] deferred follow delivery setup failed for ${remoteActorUri}: ${message}`);
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
}
|
|
225
|
+
async function sendFollow(localOxyUserId, localUsername, remoteActorUri) {
|
|
226
|
+
if (!config.federationEnabled)
|
|
227
|
+
return { success: false, pending: false };
|
|
228
|
+
if (isBlockedUrl(remoteActorUri)) {
|
|
229
|
+
logger.warn(`[FedDeliver] refusing outbound Follow to blocked origin ${remoteActorUri}`);
|
|
230
|
+
return { success: false, pending: false };
|
|
231
|
+
}
|
|
232
|
+
// Never block the follow request on a remote actor fetch. Use whatever is
|
|
233
|
+
// cached; if the actor is unknown locally we still record the follow and
|
|
234
|
+
// queue the Follow activity, then refresh the actor in the background.
|
|
235
|
+
const cached = await config.store.findActorByUri(remoteActorUri);
|
|
236
|
+
// Always refresh the actor in the background so its inbox/profile stay
|
|
237
|
+
// current (and so a missing actor gets resolved for delivery shortly).
|
|
238
|
+
config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
|
|
239
|
+
const canonicalUri = cached?.uri ?? remoteActorUri;
|
|
240
|
+
const localActorUri = urls.actor(localUsername);
|
|
241
|
+
// Use the actor _id when known, otherwise a stable hash of the URI so the
|
|
242
|
+
// activity ID is deterministic across retries before the actor is cached.
|
|
243
|
+
const activityIdSuffix = cached?._id
|
|
244
|
+
? String(cached._id)
|
|
245
|
+
: encodeURIComponent(canonicalUri);
|
|
246
|
+
const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
|
|
247
|
+
// Create or update the follow record
|
|
248
|
+
await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
|
|
249
|
+
const activity = {
|
|
250
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
251
|
+
id: activityId,
|
|
252
|
+
type: 'Follow',
|
|
253
|
+
actor: localActorUri,
|
|
254
|
+
object: canonicalUri,
|
|
255
|
+
};
|
|
256
|
+
// If we know the inbox, attempt delivery in the background; otherwise queue
|
|
257
|
+
// for the delivery worker, which resolves the inbox once the actor lands.
|
|
258
|
+
const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
|
|
259
|
+
if (targetInbox && !isBlockedUrl(targetInbox)) {
|
|
260
|
+
void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
|
|
261
|
+
.then((delivered) => {
|
|
262
|
+
if (!delivered)
|
|
263
|
+
return queueDelivery(activity, targetInbox, localOxyUserId);
|
|
264
|
+
})
|
|
265
|
+
.catch((err) => {
|
|
266
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
267
|
+
logger.warn(`[FedSync] background follow delivery failed for ${canonicalUri}: ${message}`);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
// No cached inbox yet — resolve the actor's inbox in the background and
|
|
272
|
+
// queue the Follow for delivery once known. Reports success optimistically;
|
|
273
|
+
// the delivery worker retries the queued delivery. Never blocks the caller.
|
|
274
|
+
queueFollowOnceActorKnown(activity, canonicalUri, localOxyUserId, remoteActorUri);
|
|
275
|
+
}
|
|
276
|
+
return { success: true, pending: cached?.manuallyApprovesFollowers ?? false };
|
|
277
|
+
}
|
|
278
|
+
async function sendUndoFollow(localOxyUserId, localUsername, remoteActorUri) {
|
|
279
|
+
if (!config.federationEnabled)
|
|
280
|
+
return false;
|
|
281
|
+
const follow = await config.follows.findOutbound(localOxyUserId, remoteActorUri);
|
|
282
|
+
if (!follow)
|
|
283
|
+
return false;
|
|
284
|
+
const actor = await config.store.findActorByUri(remoteActorUri);
|
|
285
|
+
if (!actor)
|
|
286
|
+
return false;
|
|
287
|
+
const localActorUri = urls.actor(localUsername);
|
|
288
|
+
const activity = {
|
|
289
|
+
'@context': AP_CONTEXT,
|
|
290
|
+
id: `${localActorUri}/follows/${actor._id}/undo`,
|
|
291
|
+
type: 'Undo',
|
|
292
|
+
actor: localActorUri,
|
|
293
|
+
object: {
|
|
294
|
+
id: follow.activityId,
|
|
295
|
+
type: 'Follow',
|
|
296
|
+
actor: localActorUri,
|
|
297
|
+
object: remoteActorUri,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
// Remove the local follow immediately so the unfollow reflects in the UI,
|
|
301
|
+
// then deliver the Undo in the background — never block the request on the
|
|
302
|
+
// remote POST.
|
|
303
|
+
await config.follows.deleteById(follow._id);
|
|
304
|
+
// `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
|
|
305
|
+
// are sending Undo(Follow) to always has one. When neither inbox is known the
|
|
306
|
+
// local follow is already removed — just skip the outbound delivery.
|
|
307
|
+
const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
|
|
308
|
+
if (targetInbox && !isBlockedUrl(targetInbox) && !isBlockedUrl(remoteActorUri)) {
|
|
309
|
+
void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
|
|
310
|
+
.then((delivered) => {
|
|
311
|
+
if (!delivered)
|
|
312
|
+
return queueDelivery(activity, targetInbox, localOxyUserId);
|
|
313
|
+
})
|
|
314
|
+
.catch((err) => {
|
|
315
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
316
|
+
logger.warn(`[FedSync] background undo-follow delivery failed for ${remoteActorUri}: ${message}`);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
else if (targetInbox) {
|
|
320
|
+
logger.warn(`[FedDeliver] skipping Undo(Follow) delivery to blocked origin ${remoteActorUri}`);
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
async function sendAccept(localOxyUserId, localUsername, followActivityId, remoteActorUri) {
|
|
325
|
+
const actor = await config.store.findActorByUri(remoteActorUri);
|
|
326
|
+
if (!actor)
|
|
327
|
+
return;
|
|
328
|
+
// `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
|
|
329
|
+
// are sending Accept(Follow) to always has one. When neither inbox is known the
|
|
330
|
+
// local follow is already removed — just skip the outbound delivery.
|
|
331
|
+
const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
|
|
332
|
+
if (!targetInbox) {
|
|
333
|
+
logger.warn(`[FedSync] cannot send Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
|
|
337
|
+
logger.warn(`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const localActorUri = urls.actor(localUsername);
|
|
341
|
+
const activity = {
|
|
342
|
+
'@context': AP_CONTEXT,
|
|
343
|
+
id: `${localActorUri}/accepts/${Date.now()}`,
|
|
344
|
+
type: 'Accept',
|
|
345
|
+
actor: localActorUri,
|
|
346
|
+
object: {
|
|
347
|
+
id: followActivityId,
|
|
348
|
+
type: 'Follow',
|
|
349
|
+
actor: remoteActorUri,
|
|
350
|
+
object: localActorUri,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const delivered = await deliverActivity(activity, targetInbox, localOxyUserId, localUsername);
|
|
354
|
+
if (!delivered) {
|
|
355
|
+
await queueDelivery(activity, targetInbox, localOxyUserId);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function federateActorUpdate(actorOxyUserId, username) {
|
|
359
|
+
if (!config.federationEnabled)
|
|
360
|
+
return;
|
|
361
|
+
if (!(await config.consent.isSharingEnabled(actorOxyUserId)))
|
|
362
|
+
return;
|
|
363
|
+
try {
|
|
364
|
+
const user = await config.identity.resolveUserByUsername(username);
|
|
365
|
+
if (!user) {
|
|
366
|
+
logger.warn(`[FedDeliver] cannot federate actor update for ${username}: user not resolvable`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const publicKey = await config.keys.getPublicKey(username);
|
|
370
|
+
const profileHeaderImage = await config.profile.getBanner(actorOxyUserId);
|
|
371
|
+
// Canonical display name is owned by the Oxy API; fall back to the handle
|
|
372
|
+
// when absent (never recompose from name parts).
|
|
373
|
+
const displayName = user.name?.displayName || username;
|
|
374
|
+
const actorObject = config.buildLocalActorObject({
|
|
375
|
+
username,
|
|
376
|
+
displayName,
|
|
377
|
+
kind: user.kind,
|
|
378
|
+
bio: user.bio,
|
|
379
|
+
avatar: user.avatar,
|
|
380
|
+
profileHeaderImage,
|
|
381
|
+
publicKey,
|
|
382
|
+
createdAt: user.createdAt,
|
|
383
|
+
});
|
|
384
|
+
const actor = urls.actor(username);
|
|
385
|
+
const now = new Date();
|
|
386
|
+
const activity = {
|
|
387
|
+
'@context': AP_CONTEXT,
|
|
388
|
+
id: `${actor}#updates/${now.getTime()}`,
|
|
389
|
+
type: 'Update',
|
|
390
|
+
actor,
|
|
391
|
+
updated: now.toISOString(),
|
|
392
|
+
to: [AP_PUBLIC],
|
|
393
|
+
cc: [`${actor}/followers`],
|
|
394
|
+
object: actorObject,
|
|
395
|
+
};
|
|
396
|
+
await deliverToFollowers(activity, actorOxyUserId, username);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
logger.error('Failed to federate actor update:', err);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
deliverActivity,
|
|
404
|
+
queueDelivery,
|
|
405
|
+
resolveActorInbox,
|
|
406
|
+
deliverToFollowers,
|
|
407
|
+
sendFollow,
|
|
408
|
+
sendUndoFollow,
|
|
409
|
+
sendAccept,
|
|
410
|
+
federateActorUpdate,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network-neutral identity bridge — the DEFAULT implementation of the
|
|
3
|
+
* actor↔Oxy-user seam.
|
|
4
|
+
*
|
|
5
|
+
* The DATA is Oxy's: a remote actor is minted/updated as a `type:'federated'` Oxy
|
|
6
|
+
* user via `PUT /users/resolve`, and permanently-gone actors are archived
|
|
7
|
+
* (`POST /federation/actor-gone`) or hard-deleted (`POST /federation/actor-delete`)
|
|
8
|
+
* on the Oxy side. Those are service-scoped oxy-api calls (scope `federation:write`),
|
|
9
|
+
* so the transport is injected ({@link IdentityBridgeConfig.makeServiceRequest} —
|
|
10
|
+
* for Mention, `getServiceOxyClient().makeServiceRequest`). Private keys and
|
|
11
|
+
* canonical identity never enter this package.
|
|
12
|
+
*
|
|
13
|
+
* App-owned side effects are injected too: a post-resolve cache invalidation hook
|
|
14
|
+
* ({@link IdentityBridgeConfig.onUserResolved}) and the banner mirror
|
|
15
|
+
* ({@link IdentityBridgeConfig.mirrorBanner}, which uses the app's own media
|
|
16
|
+
* pipeline). The banner mirror is best-effort: a failure there must never discard
|
|
17
|
+
* an already-successful user resolution.
|
|
18
|
+
*/
|
|
19
|
+
import { getErrorMessage, getErrorStatus } from '@oxy.so/core';
|
|
20
|
+
const ACTOR_GONE_PATH = '/federation/actor-gone';
|
|
21
|
+
const ACTOR_DELETE_PATH = '/federation/actor-delete';
|
|
22
|
+
/** True for a PERMANENT (non-retryable) 4xx: 400/403/404/409, but NOT 408/429. */
|
|
23
|
+
function isPermanentClientError(httpStatus) {
|
|
24
|
+
return (httpStatus !== undefined &&
|
|
25
|
+
httpStatus >= 400 &&
|
|
26
|
+
httpStatus < 500 &&
|
|
27
|
+
httpStatus !== 408 &&
|
|
28
|
+
httpStatus !== 429);
|
|
29
|
+
}
|
|
30
|
+
/** Build the network-neutral identity bridge from an app's adapters. */
|
|
31
|
+
export function createIdentityBridge(config) {
|
|
32
|
+
return {
|
|
33
|
+
async resolveExternalUser(actor, opts = {}) {
|
|
34
|
+
const forceAvatarRefresh = opts.forceAvatarRefresh ?? false;
|
|
35
|
+
try {
|
|
36
|
+
// The connector owns deriving the canonical `local@domain` username and the
|
|
37
|
+
// instance domain for its protocol, so this bridge stays protocol-agnostic:
|
|
38
|
+
// it never has to guess a domain out of a bare atproto handle or a hostless
|
|
39
|
+
// DID. oxy-api binds the two (username domain must equal `domain`).
|
|
40
|
+
const oxyUser = await config.makeServiceRequest('PUT', '/users/resolve', {
|
|
41
|
+
type: 'federated',
|
|
42
|
+
username: actor.federatedUsername,
|
|
43
|
+
actorUri: actor.externalId,
|
|
44
|
+
domain: actor.instanceDomain,
|
|
45
|
+
displayName: actor.displayName,
|
|
46
|
+
avatar: actor.avatarUrl,
|
|
47
|
+
bio: actor.bio,
|
|
48
|
+
// On refresh, tell Oxy to re-download and replace the avatar even if it
|
|
49
|
+
// already stored a file ID. Coordinated with oxy-api's
|
|
50
|
+
// `refresh` / `forceAvatarRefresh` flag on PUT /users/resolve.
|
|
51
|
+
refresh: forceAvatarRefresh,
|
|
52
|
+
forceAvatarRefresh,
|
|
53
|
+
});
|
|
54
|
+
const oxyId = String(oxyUser?._id || oxyUser?.id || '');
|
|
55
|
+
if (!oxyId)
|
|
56
|
+
return null;
|
|
57
|
+
// A re-resolve can refresh the federated actor's display name / avatar in
|
|
58
|
+
// Oxy. Let the app evict any warm cache so the next read is fresh.
|
|
59
|
+
if (config.onUserResolved) {
|
|
60
|
+
await config.onUserResolved(oxyId);
|
|
61
|
+
}
|
|
62
|
+
if (actor.bannerUrl && config.mirrorBanner) {
|
|
63
|
+
// Best-effort on the live path: the mirror handles its own failures.
|
|
64
|
+
await config.mirrorBanner(actor.bannerUrl, oxyId, actor.externalId);
|
|
65
|
+
}
|
|
66
|
+
return oxyId;
|
|
67
|
+
}
|
|
68
|
+
catch (resolveErr) {
|
|
69
|
+
config.logger.warn(`Failed to resolve Oxy user for ${actor.externalId}:`, resolveErr);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
async reportActorGone(oxyUserId) {
|
|
74
|
+
const id = oxyUserId.trim();
|
|
75
|
+
if (!id)
|
|
76
|
+
return 'skipped';
|
|
77
|
+
try {
|
|
78
|
+
const data = await config.makeServiceRequest('POST', ACTOR_GONE_PATH, {
|
|
79
|
+
oxyUserId: id,
|
|
80
|
+
});
|
|
81
|
+
const alreadyArchived = data?.alreadyArchived === true;
|
|
82
|
+
config.logger.info(`[Federation] oxy-api archived gone actor ${id}`, { alreadyArchived });
|
|
83
|
+
return alreadyArchived ? 'already' : 'archived';
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const httpStatus = getErrorStatus(error);
|
|
87
|
+
const reason = getErrorMessage(error);
|
|
88
|
+
if (isPermanentClientError(httpStatus)) {
|
|
89
|
+
config.logger.warn(`[Federation] actor-gone report for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
|
|
90
|
+
reason,
|
|
91
|
+
});
|
|
92
|
+
return 'skipped';
|
|
93
|
+
}
|
|
94
|
+
config.logger.warn(`[Federation] actor-gone report for ${id} failed transiently; leaving for retry`, {
|
|
95
|
+
status: httpStatus,
|
|
96
|
+
reason,
|
|
97
|
+
});
|
|
98
|
+
return 'failed';
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async deleteActorIdentity(oxyUserId) {
|
|
102
|
+
const id = oxyUserId.trim();
|
|
103
|
+
if (!id)
|
|
104
|
+
return 'skipped';
|
|
105
|
+
try {
|
|
106
|
+
const data = await config.makeServiceRequest('POST', ACTOR_DELETE_PATH, {
|
|
107
|
+
oxyUserId: id,
|
|
108
|
+
});
|
|
109
|
+
const deleted = data?.deleted === true;
|
|
110
|
+
config.logger.info(`[Federation] oxy-api ${deleted ? 'hard-deleted' : 'found no'} identity for gone actor ${id}`, { followEdgesRemoved: data?.followEdgesRemoved ?? 0 });
|
|
111
|
+
return deleted ? 'deleted' : 'absent';
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const httpStatus = getErrorStatus(error);
|
|
115
|
+
const reason = getErrorMessage(error);
|
|
116
|
+
if (isPermanentClientError(httpStatus)) {
|
|
117
|
+
config.logger.warn(`[Federation] actor-delete for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
|
|
118
|
+
reason,
|
|
119
|
+
});
|
|
120
|
+
return 'skipped';
|
|
121
|
+
}
|
|
122
|
+
config.logger.warn(`[Federation] actor-delete for ${id} failed transiently; leaving for retry`, {
|
|
123
|
+
status: httpStatus,
|
|
124
|
+
reason,
|
|
125
|
+
});
|
|
126
|
+
return 'failed';
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|