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