@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,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The ActivityPub actor + inbox + follow-graph router.
|
|
4
|
+
*
|
|
5
|
+
* Serves the engine-owned half of the `/ap` namespace:
|
|
6
|
+
* - `GET /users/:username` — the local `Person` actor (and the special `instance`
|
|
7
|
+
* Application actor used for signed fetches),
|
|
8
|
+
* - `POST /users/:username/inbox` + `POST /inbox` — inbound delivery, with HTTP
|
|
9
|
+
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
10
|
+
* 202 + async dispatch to the injected inbound dispatcher,
|
|
11
|
+
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
12
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s.
|
|
13
|
+
*
|
|
14
|
+
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
15
|
+
* mounted on the SAME `/ap/users/:username/*` prefix the actor advertises.
|
|
16
|
+
*
|
|
17
|
+
* Extracted behaviour-identically from Mention's `ap.routes.ts`. Everything
|
|
18
|
+
* app-specific — the actor's Oxy profile, the banner, the fediverse-sharing gate,
|
|
19
|
+
* the public-key lookup, the inbox enqueue transport, the follow-graph page fetch
|
|
20
|
+
* — is injected.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.createActorRouter = createActorRouter;
|
|
24
|
+
const express_1 = require("express");
|
|
25
|
+
const apContext_1 = require("../apContext");
|
|
26
|
+
const httpSignature_1 = require("../httpSignature");
|
|
27
|
+
const urls_1 = require("../urls");
|
|
28
|
+
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
29
|
+
const FOLLOW_PAGE_SIZE = 20;
|
|
30
|
+
/** Extract the `:username` param safely as a string. */
|
|
31
|
+
function getUsername(req) {
|
|
32
|
+
const val = req.params.username;
|
|
33
|
+
const raw = typeof val === 'string' ? val : Array.isArray(val) ? val[0] : String(val);
|
|
34
|
+
return (0, urls_1.normalizeActorUsername)(raw);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Map a follow-graph member (an Oxy `User`) to its ActivityPub actor URI:
|
|
38
|
+
* - a LOCAL Oxy/Mention user → our minted actor URL,
|
|
39
|
+
* - a FEDERATED user → the remote actor URI on `federation.actorUri`.
|
|
40
|
+
* Returns null when unmappable (skip — never emit a raw oxyUserId as an actor id).
|
|
41
|
+
*/
|
|
42
|
+
function memberActorUri(user, urls) {
|
|
43
|
+
const isFederated = user.type === 'federated' || user.isFederated === true;
|
|
44
|
+
if (isFederated) {
|
|
45
|
+
const uri = user.federation?.actorUri;
|
|
46
|
+
return typeof uri === 'string' && uri.length > 0 ? uri : null;
|
|
47
|
+
}
|
|
48
|
+
const { username } = user;
|
|
49
|
+
return typeof username === 'string' && username.length > 0 ? urls.actor(username) : null;
|
|
50
|
+
}
|
|
51
|
+
/** Parse a non-negative page offset from the request query (missing/invalid ⇒ 0). */
|
|
52
|
+
function parseFollowOffset(raw) {
|
|
53
|
+
const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number.NaN;
|
|
54
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
/** Build the actor + inbox + follow-graph router for an app's domain. */
|
|
57
|
+
function createActorRouter(config) {
|
|
58
|
+
const router = (0, express_1.Router)();
|
|
59
|
+
const { urls, domain, apContentType, logger } = config;
|
|
60
|
+
function wantsActivityPub(req) {
|
|
61
|
+
return config.wantsActivityPub(req.headers.accept);
|
|
62
|
+
}
|
|
63
|
+
/** Common inbox handler with HTTP signature verification. */
|
|
64
|
+
async function handleInbox(req, res) {
|
|
65
|
+
try {
|
|
66
|
+
// Verify HTTP signature (use originalUrl to avoid proxy path mangling).
|
|
67
|
+
const { verified, actorUri, reason: signatureError } = await (0, httpSignature_1.verifyHttpSignature)({
|
|
68
|
+
method: req.method,
|
|
69
|
+
path: req.originalUrl || req.path,
|
|
70
|
+
headers: req.headers,
|
|
71
|
+
body: req.rawBody ?? req.body,
|
|
72
|
+
}, (keyId) => config.inbound.fetchPublicKey(keyId), {
|
|
73
|
+
trustForwardedHost: config.inbound.trustForwardedHost,
|
|
74
|
+
onDebug: (message, detail) => logger.debug(message, detail),
|
|
75
|
+
});
|
|
76
|
+
if (!verified || !actorUri) {
|
|
77
|
+
logger.debug('Inbox: HTTP signature verification failed', { reason: signatureError });
|
|
78
|
+
return res.status(401).json({ error: 'Invalid signature' });
|
|
79
|
+
}
|
|
80
|
+
const activity = req.body;
|
|
81
|
+
if (!activity || !activity.type) {
|
|
82
|
+
return res.status(400).json({ error: 'Invalid activity' });
|
|
83
|
+
}
|
|
84
|
+
// Verify the actor in the activity matches the signature.
|
|
85
|
+
const activityActor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
86
|
+
if (activityActor !== actorUri) {
|
|
87
|
+
logger.debug(`Inbox: Actor mismatch. Signed: ${actorUri}, Activity: ${activityActor}`);
|
|
88
|
+
return res.status(403).json({ error: 'Actor mismatch' });
|
|
89
|
+
}
|
|
90
|
+
// Process asynchronously — return 202 Accepted immediately. Durable path:
|
|
91
|
+
// enqueue onto BullMQ keyed by the activity id (dedupe). When the queue is
|
|
92
|
+
// unavailable (Redis not configured) OR the activity has no stable id to
|
|
93
|
+
// dedupe on, fall back to inline fire-and-forget processing so the activity
|
|
94
|
+
// is never dropped.
|
|
95
|
+
let enqueued = false;
|
|
96
|
+
try {
|
|
97
|
+
enqueued = await config.inbound.enqueueInboxActivity({ activity, verifiedActorUri: actorUri });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
logger.error('Failed to enqueue inbox activity — processing inline:', err);
|
|
101
|
+
enqueued = false;
|
|
102
|
+
}
|
|
103
|
+
if (!enqueued) {
|
|
104
|
+
config.inbound.processInboxActivity(activity, actorUri).catch((err) => {
|
|
105
|
+
logger.error('Error processing inbox activity:', err);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return res.status(202).json({ status: 'accepted' });
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger.error('Inbox error:', err);
|
|
112
|
+
return res.status(500).json({ error: 'Internal server error' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
117
|
+
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
118
|
+
*/
|
|
119
|
+
async function serveFollowCollection(req, res, direction, collectionUrl) {
|
|
120
|
+
if (!config.federationEnabled)
|
|
121
|
+
return res.status(404).json({ error: 'Federation disabled' });
|
|
122
|
+
const username = getUsername(req);
|
|
123
|
+
const page = req.query.page === 'true';
|
|
124
|
+
try {
|
|
125
|
+
const user = await config.resolveUser(username);
|
|
126
|
+
if (!user)
|
|
127
|
+
return res.status(404).json({ error: 'User not found' });
|
|
128
|
+
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
129
|
+
return res.status(404).json({ error: 'User not found' });
|
|
130
|
+
}
|
|
131
|
+
const userId = String(user._id || user.id);
|
|
132
|
+
const rawCount = direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
133
|
+
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
134
|
+
if (!page) {
|
|
135
|
+
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
136
|
+
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
137
|
+
// to 0 — never 500 the summary.
|
|
138
|
+
let totalItems = profileTotal ?? 0;
|
|
139
|
+
if (profileTotal === undefined) {
|
|
140
|
+
try {
|
|
141
|
+
totalItems = (await config.fetchFollowPage(userId, direction, 0, 1)).total;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
logger.warn('[Federation] follow-collection summary total lookup failed', {
|
|
145
|
+
username, direction, error: err,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
res.set('Content-Type', apContentType);
|
|
150
|
+
return res.json({
|
|
151
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
152
|
+
id: collectionUrl(username),
|
|
153
|
+
type: 'OrderedCollection',
|
|
154
|
+
totalItems,
|
|
155
|
+
first: `${collectionUrl(username)}?page=true`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const offset = parseFollowOffset(req.query.offset);
|
|
159
|
+
let members = [];
|
|
160
|
+
let total = profileTotal ?? 0;
|
|
161
|
+
let hasMore = false;
|
|
162
|
+
try {
|
|
163
|
+
const pageResult = await config.fetchFollowPage(userId, direction, offset, FOLLOW_PAGE_SIZE);
|
|
164
|
+
members = pageResult.members;
|
|
165
|
+
total = pageResult.total;
|
|
166
|
+
hasMore = pageResult.hasMore;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
// Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
|
|
170
|
+
// an empty page against the best-known total rather than crashing.
|
|
171
|
+
logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
|
|
172
|
+
username, direction, offset, error: err,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const orderedItems = members
|
|
176
|
+
.map((member) => memberActorUri(member, urls))
|
|
177
|
+
.filter((uri) => uri !== null);
|
|
178
|
+
const pageId = offset > 0
|
|
179
|
+
? `${collectionUrl(username)}?page=true&offset=${offset}`
|
|
180
|
+
: `${collectionUrl(username)}?page=true`;
|
|
181
|
+
const pageResponse = {
|
|
182
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
183
|
+
id: pageId,
|
|
184
|
+
type: 'OrderedCollectionPage',
|
|
185
|
+
partOf: collectionUrl(username),
|
|
186
|
+
totalItems: total,
|
|
187
|
+
orderedItems,
|
|
188
|
+
};
|
|
189
|
+
if (hasMore) {
|
|
190
|
+
pageResponse.next = `${collectionUrl(username)}?page=true&offset=${offset + FOLLOW_PAGE_SIZE}`;
|
|
191
|
+
}
|
|
192
|
+
res.set('Content-Type', apContentType);
|
|
193
|
+
return res.json(pageResponse);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.error('Follow collection endpoint error:', err);
|
|
197
|
+
return res.status(500).json({ error: 'Internal server error' });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// GET /ap/users/:username — ActivityPub Actor endpoint
|
|
201
|
+
router.get('/users/:username', async (req, res) => {
|
|
202
|
+
if (!config.federationEnabled)
|
|
203
|
+
return res.status(404).json({ error: 'Federation disabled' });
|
|
204
|
+
if (!wantsActivityPub(req)) {
|
|
205
|
+
// Redirect to the frontend profile if not an AP request.
|
|
206
|
+
return res.redirect(`https://${domain}/@${getUsername(req)}`);
|
|
207
|
+
}
|
|
208
|
+
const username = getUsername(req);
|
|
209
|
+
try {
|
|
210
|
+
// Instance actor: a special server-level actor used for signed fetches. It
|
|
211
|
+
// has no Oxy user — serve it directly from the key material. The WebFinger
|
|
212
|
+
// router answers for the SAME username, and its `self` href is built from
|
|
213
|
+
// the same `urls.actor(INSTANCE_ACTOR_USERNAME)` call as the `id` below,
|
|
214
|
+
// because Mastodon rejects a signed fetch when the two disagree.
|
|
215
|
+
if (username === urls_1.INSTANCE_ACTOR_USERNAME) {
|
|
216
|
+
const publicKey = await config.getPublicKey(urls_1.INSTANCE_ACTOR_USERNAME);
|
|
217
|
+
const actorObject = {
|
|
218
|
+
'@context': apContext_1.AP_CONTEXT,
|
|
219
|
+
id: urls.actor(urls_1.INSTANCE_ACTOR_USERNAME),
|
|
220
|
+
type: 'Application',
|
|
221
|
+
preferredUsername: urls_1.INSTANCE_ACTOR_USERNAME,
|
|
222
|
+
name: domain,
|
|
223
|
+
summary: '',
|
|
224
|
+
url: `https://${domain}`,
|
|
225
|
+
inbox: urls.inbox(urls_1.INSTANCE_ACTOR_USERNAME),
|
|
226
|
+
outbox: urls.outbox(urls_1.INSTANCE_ACTOR_USERNAME),
|
|
227
|
+
endpoints: { sharedInbox: urls.sharedInbox() },
|
|
228
|
+
manuallyApprovesFollowers: false,
|
|
229
|
+
discoverable: false,
|
|
230
|
+
publicKey: {
|
|
231
|
+
id: publicKey.keyId,
|
|
232
|
+
owner: urls.actor(urls_1.INSTANCE_ACTOR_USERNAME),
|
|
233
|
+
publicKeyPem: publicKey.publicKeyPem,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
res.set('Content-Type', apContentType);
|
|
237
|
+
res.set('Cache-Control', 'max-age=1800');
|
|
238
|
+
return res.json(actorObject);
|
|
239
|
+
}
|
|
240
|
+
const user = await config.resolveUser(username);
|
|
241
|
+
if (!user)
|
|
242
|
+
return res.status(404).json({ error: 'User not found' });
|
|
243
|
+
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
244
|
+
// body, no separate error code. Derived from the already-resolved user.
|
|
245
|
+
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
246
|
+
return res.status(404).json({ error: 'User not found' });
|
|
247
|
+
}
|
|
248
|
+
const publicKey = await config.getPublicKey(username);
|
|
249
|
+
// The profile banner lives in the app's own per-user settings (not the Oxy
|
|
250
|
+
// user DTO), keyed by the resolved Oxy user id. Advertise it as the AP
|
|
251
|
+
// `image` (Mastodon header). Absent settings / banner cleanly omits it.
|
|
252
|
+
const userId = user._id || user.id;
|
|
253
|
+
const profileHeaderImage = userId ? await config.getBanner(String(userId)) : null;
|
|
254
|
+
// Canonical display name is owned by the Oxy API (`name.displayName`); fall
|
|
255
|
+
// back to the username only if the API omitted it, so `name` is never empty.
|
|
256
|
+
const displayName = user.name?.displayName || username;
|
|
257
|
+
// ONE actor builder — shared with the outbound `Update(Person)` broadcast — so
|
|
258
|
+
// a fetched actor and a pushed actor Update never drift. The route owns the
|
|
259
|
+
// top-level JSON-LD `@context` (the builder omits it).
|
|
260
|
+
const actorObject = config.buildLocalActorObject({
|
|
261
|
+
username,
|
|
262
|
+
displayName,
|
|
263
|
+
kind: user.kind,
|
|
264
|
+
bio: user.bio,
|
|
265
|
+
avatar: user.avatar,
|
|
266
|
+
profileHeaderImage,
|
|
267
|
+
publicKey,
|
|
268
|
+
createdAt: user.createdAt,
|
|
269
|
+
});
|
|
270
|
+
res.set('Content-Type', apContentType);
|
|
271
|
+
res.set('Cache-Control', 'max-age=1800');
|
|
272
|
+
return res.json({ '@context': apContext_1.AP_CONTEXT, ...actorObject });
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
logger.error('Actor endpoint error:', err);
|
|
276
|
+
return res.status(500).json({ error: 'Internal server error' });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
// POST /ap/users/:username/inbox — User inbox
|
|
280
|
+
router.post('/users/:username/inbox', async (req, res) => {
|
|
281
|
+
if (!config.federationEnabled)
|
|
282
|
+
return res.status(404).json({ error: 'Federation disabled' });
|
|
283
|
+
// Sharing OFF (or a bogus `:username`) must be indistinguishable from a
|
|
284
|
+
// nonexistent user — same 404 body. An Oxy OUTAGE ('unavailable') is
|
|
285
|
+
// deliberately NOT 404'd: this is a POST delivery, and a 4xx makes the remote
|
|
286
|
+
// server drop it permanently rather than retry, so availability wins over
|
|
287
|
+
// gating freshness — the activity is processed and any consent decision is
|
|
288
|
+
// re-checked downstream by the id-based (fail-open) gates.
|
|
289
|
+
const username = getUsername(req);
|
|
290
|
+
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
291
|
+
if (sharingState === 'disabled' || sharingState === 'unknown-user') {
|
|
292
|
+
return res.status(404).json({ error: 'User not found' });
|
|
293
|
+
}
|
|
294
|
+
return handleInbox(req, res);
|
|
295
|
+
});
|
|
296
|
+
// POST /ap/inbox — Shared inbox
|
|
297
|
+
router.post('/inbox', async (req, res) => {
|
|
298
|
+
if (!config.federationEnabled)
|
|
299
|
+
return res.status(404).json({ error: 'Federation disabled' });
|
|
300
|
+
return handleInbox(req, res);
|
|
301
|
+
});
|
|
302
|
+
// GET /ap/users/:username/followers — Followers collection (Oxy graph: local + federated).
|
|
303
|
+
router.get('/users/:username/followers', (req, res) => serveFollowCollection(req, res, 'followers', urls.followers));
|
|
304
|
+
// GET /ap/users/:username/following — Following collection (Oxy graph: local + federated).
|
|
305
|
+
router.get('/users/:username/following', (req, res) => serveFollowCollection(req, res, 'following', urls.following));
|
|
306
|
+
return router;
|
|
307
|
+
}
|