@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,438 @@
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
+
22
+ import { Router, type Request, type Response } from 'express';
23
+ import type { AccountKind } from '@oxy.so/contracts';
24
+ import type { User } from '@oxy.so/core';
25
+ import { AP_CONTEXT } from '../apContext';
26
+ import { verifyHttpSignature } from '../httpSignature';
27
+ import type { UrlBuilders } from '../urls';
28
+ import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
29
+ import type { LocalActorBuilder } from '../actorObject';
30
+
31
+ /** Page size for the paginated followers/following collections (mirrors the outbox). */
32
+ const FOLLOW_PAGE_SIZE = 20;
33
+
34
+ /** The resolved-user fields the actor + collection routes read. */
35
+ export interface ActorRouteUser {
36
+ _id?: string | null;
37
+ id?: string | null;
38
+ name?: { displayName?: string | null } | null;
39
+ bio?: string | null;
40
+ avatar?: string | null;
41
+ createdAt?: string | null;
42
+ /** Account-graph classification — decides the actor `type`. */
43
+ kind?: AccountKind | null;
44
+ _count?: { followers?: number; following?: number } | null;
45
+ }
46
+
47
+ /** The tri-state consent read for a username with no already-resolved user object. */
48
+ export type ActorSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
49
+
50
+ /** An inbound request may carry the raw (pre-parse) body used for digest verification. */
51
+ interface InboxRequest extends Request {
52
+ rawBody?: unknown;
53
+ }
54
+
55
+ /** Minimal logging sink the actor router writes to. */
56
+ export interface ActorRouterLogger {
57
+ debug(message: string, detail?: unknown): void;
58
+ warn(message: string, detail?: unknown): void;
59
+ error(message: string, detail?: unknown): void;
60
+ }
61
+
62
+ /** A page of a user's follow graph (from the authoritative Oxy graph). */
63
+ export interface FollowPage {
64
+ members: User[];
65
+ total: number;
66
+ hasMore: boolean;
67
+ }
68
+
69
+ /** Adapters + config a {@link createActorRouter} is built from. */
70
+ export interface ActorRouterConfig {
71
+ /** The app's federation domain (the human-facing `url` host + non-AP redirect target). */
72
+ domain: string;
73
+ /** Whether federation is enabled (all routes 404 when off). */
74
+ federationEnabled: boolean;
75
+ /** The AP content type (`application/activity+json`). */
76
+ apContentType: string;
77
+ /** Per-instance URL builders. */
78
+ urls: UrlBuilders;
79
+ /** True when the request's Accept header asks for ActivityPub JSON. */
80
+ wantsActivityPub(accept: string | string[] | undefined): boolean;
81
+ /** Fetch the public keyId + PEM for a username (`instance` for the server actor). */
82
+ getPublicKey(username: string): Promise<{ keyId: string; publicKeyPem: string }>;
83
+ /** Resolve a username to its Oxy user (null when unknown). */
84
+ resolveUser(username: string): Promise<ActorRouteUser | null>;
85
+ /** The fediverse-sharing consent gate. */
86
+ consent: {
87
+ isSharingEnabledFromUser(user: ActorRouteUser): boolean;
88
+ getSharingStateByUsername(username: string): Promise<ActorSharingState>;
89
+ };
90
+ /** The single local-actor builder (shared with the `Update(Person)` broadcast). */
91
+ buildLocalActorObject: LocalActorBuilder;
92
+ /** The app-owned profile banner (Mention: `UserSettings.profileHeaderImage`). */
93
+ getBanner(oxyUserId: string): Promise<string | null>;
94
+ /** Inbound-delivery adapters. */
95
+ inbound: {
96
+ /** Resolve a `keyId` to its public key PEM + owning actor uri (HTTP-sig verify). */
97
+ fetchPublicKey(keyId: string): Promise<{ publicKeyPem: string; actorUri: string } | null>;
98
+ /** Whether to trust `X-Forwarded-Host` when reconstructing the signed host line. */
99
+ trustForwardedHost: boolean;
100
+ /** Enqueue a verified inbound activity for async processing (false ⇒ process inline). */
101
+ enqueueInboxActivity(job: { activity: Record<string, unknown>; verifiedActorUri: string }): Promise<boolean>;
102
+ /** The inbound dispatcher (the inline-fallback + post-enqueue processor). */
103
+ processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
104
+ };
105
+ /** Fetch one page of a user's Oxy follow graph (followers OR following). */
106
+ fetchFollowPage(
107
+ userId: string,
108
+ direction: 'followers' | 'following',
109
+ offset: number,
110
+ limit: number,
111
+ ): Promise<FollowPage>;
112
+ /** Diagnostics sink. */
113
+ logger: ActorRouterLogger;
114
+ }
115
+
116
+ /** Extract the `:username` param safely as a string. */
117
+ function getUsername(req: Request): string {
118
+ const val = req.params.username;
119
+ const raw = typeof val === 'string' ? val : Array.isArray(val) ? val[0] : String(val);
120
+ return normalizeActorUsername(raw);
121
+ }
122
+
123
+ /**
124
+ * Map a follow-graph member (an Oxy `User`) to its ActivityPub actor URI:
125
+ * - a LOCAL Oxy/Mention user → our minted actor URL,
126
+ * - a FEDERATED user → the remote actor URI on `federation.actorUri`.
127
+ * Returns null when unmappable (skip — never emit a raw oxyUserId as an actor id).
128
+ */
129
+ function memberActorUri(user: User, urls: UrlBuilders): string | null {
130
+ const isFederated = user.type === 'federated' || user.isFederated === true;
131
+ if (isFederated) {
132
+ const uri = user.federation?.actorUri;
133
+ return typeof uri === 'string' && uri.length > 0 ? uri : null;
134
+ }
135
+ const { username } = user;
136
+ return typeof username === 'string' && username.length > 0 ? urls.actor(username) : null;
137
+ }
138
+
139
+ /** Parse a non-negative page offset from the request query (missing/invalid ⇒ 0). */
140
+ function parseFollowOffset(raw: unknown): number {
141
+ const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number.NaN;
142
+ return Number.isFinite(value) && value > 0 ? value : 0;
143
+ }
144
+
145
+ /** Build the actor + inbox + follow-graph router for an app's domain. */
146
+ export function createActorRouter(config: ActorRouterConfig): Router {
147
+ const router = Router();
148
+ const { urls, domain, apContentType, logger } = config;
149
+
150
+ function wantsActivityPub(req: Request): boolean {
151
+ return config.wantsActivityPub(req.headers.accept);
152
+ }
153
+
154
+ /** Common inbox handler with HTTP signature verification. */
155
+ async function handleInbox(req: InboxRequest, res: Response): Promise<Response> {
156
+ try {
157
+ // Verify HTTP signature (use originalUrl to avoid proxy path mangling).
158
+ const { verified, actorUri, reason: signatureError } = await verifyHttpSignature(
159
+ {
160
+ method: req.method,
161
+ path: req.originalUrl || req.path,
162
+ headers: req.headers as Record<string, string | string[] | undefined>,
163
+ body: req.rawBody ?? req.body,
164
+ },
165
+ (keyId) => config.inbound.fetchPublicKey(keyId),
166
+ {
167
+ trustForwardedHost: config.inbound.trustForwardedHost,
168
+ onDebug: (message, detail) => logger.debug(message, detail),
169
+ },
170
+ );
171
+
172
+ if (!verified || !actorUri) {
173
+ logger.debug('Inbox: HTTP signature verification failed', { reason: signatureError });
174
+ return res.status(401).json({ error: 'Invalid signature' });
175
+ }
176
+
177
+ const activity = req.body;
178
+ if (!activity || !activity.type) {
179
+ return res.status(400).json({ error: 'Invalid activity' });
180
+ }
181
+
182
+ // Verify the actor in the activity matches the signature.
183
+ const activityActor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
184
+ if (activityActor !== actorUri) {
185
+ logger.debug(`Inbox: Actor mismatch. Signed: ${actorUri}, Activity: ${activityActor}`);
186
+ return res.status(403).json({ error: 'Actor mismatch' });
187
+ }
188
+
189
+ // Process asynchronously — return 202 Accepted immediately. Durable path:
190
+ // enqueue onto BullMQ keyed by the activity id (dedupe). When the queue is
191
+ // unavailable (Redis not configured) OR the activity has no stable id to
192
+ // dedupe on, fall back to inline fire-and-forget processing so the activity
193
+ // is never dropped.
194
+ let enqueued = false;
195
+ try {
196
+ enqueued = await config.inbound.enqueueInboxActivity({ activity, verifiedActorUri: actorUri });
197
+ } catch (err) {
198
+ logger.error('Failed to enqueue inbox activity — processing inline:', err);
199
+ enqueued = false;
200
+ }
201
+
202
+ if (!enqueued) {
203
+ config.inbound.processInboxActivity(activity, actorUri).catch((err) => {
204
+ logger.error('Error processing inbox activity:', err);
205
+ });
206
+ }
207
+
208
+ return res.status(202).json({ status: 'accepted' });
209
+ } catch (err) {
210
+ logger.error('Inbox error:', err);
211
+ return res.status(500).json({ error: 'Internal server error' });
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Serve a user's followers OR following as a paginated `OrderedCollection` over
217
+ * the authoritative Oxy follow graph (local + bridged federated edges).
218
+ */
219
+ async function serveFollowCollection(
220
+ req: Request,
221
+ res: Response,
222
+ direction: 'followers' | 'following',
223
+ collectionUrl: (username: string) => string,
224
+ ): Promise<Response> {
225
+ if (!config.federationEnabled) return res.status(404).json({ error: 'Federation disabled' });
226
+
227
+ const username = getUsername(req);
228
+ const page = req.query.page === 'true';
229
+
230
+ try {
231
+ const user = await config.resolveUser(username);
232
+ if (!user) return res.status(404).json({ error: 'User not found' });
233
+
234
+ if (!config.consent.isSharingEnabledFromUser(user)) {
235
+ return res.status(404).json({ error: 'User not found' });
236
+ }
237
+
238
+ const userId = String(user._id || user.id);
239
+
240
+ const rawCount: unknown = direction === 'followers' ? user._count?.followers : user._count?.following;
241
+ const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
242
+
243
+ if (!page) {
244
+ // Prefer the profile `_count`; only when absent (the rare search fallback)
245
+ // fetch the authoritative total from a minimal graph list call. Fail-soft
246
+ // to 0 — never 500 the summary.
247
+ let totalItems = profileTotal ?? 0;
248
+ if (profileTotal === undefined) {
249
+ try {
250
+ totalItems = (await config.fetchFollowPage(userId, direction, 0, 1)).total;
251
+ } catch (err) {
252
+ logger.warn('[Federation] follow-collection summary total lookup failed', {
253
+ username, direction, error: err,
254
+ });
255
+ }
256
+ }
257
+
258
+ res.set('Content-Type', apContentType);
259
+ return res.json({
260
+ '@context': AP_CONTEXT,
261
+ id: collectionUrl(username),
262
+ type: 'OrderedCollection',
263
+ totalItems,
264
+ first: `${collectionUrl(username)}?page=true`,
265
+ });
266
+ }
267
+
268
+ const offset = parseFollowOffset(req.query.offset);
269
+
270
+ let members: User[] = [];
271
+ let total = profileTotal ?? 0;
272
+ let hasMore = false;
273
+ try {
274
+ const pageResult = await config.fetchFollowPage(userId, direction, offset, FOLLOW_PAGE_SIZE);
275
+ members = pageResult.members;
276
+ total = pageResult.total;
277
+ hasMore = pageResult.hasMore;
278
+ } catch (err) {
279
+ // Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
280
+ // an empty page against the best-known total rather than crashing.
281
+ logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
282
+ username, direction, offset, error: err,
283
+ });
284
+ }
285
+
286
+ const orderedItems = members
287
+ .map((member) => memberActorUri(member, urls))
288
+ .filter((uri): uri is string => uri !== null);
289
+
290
+ const pageId = offset > 0
291
+ ? `${collectionUrl(username)}?page=true&offset=${offset}`
292
+ : `${collectionUrl(username)}?page=true`;
293
+
294
+ const pageResponse: Record<string, unknown> = {
295
+ '@context': AP_CONTEXT,
296
+ id: pageId,
297
+ type: 'OrderedCollectionPage',
298
+ partOf: collectionUrl(username),
299
+ totalItems: total,
300
+ orderedItems,
301
+ };
302
+
303
+ if (hasMore) {
304
+ pageResponse.next = `${collectionUrl(username)}?page=true&offset=${offset + FOLLOW_PAGE_SIZE}`;
305
+ }
306
+
307
+ res.set('Content-Type', apContentType);
308
+ return res.json(pageResponse);
309
+ } catch (err) {
310
+ logger.error('Follow collection endpoint error:', err);
311
+ return res.status(500).json({ error: 'Internal server error' });
312
+ }
313
+ }
314
+
315
+ // GET /ap/users/:username — ActivityPub Actor endpoint
316
+ router.get('/users/:username', async (req: Request, res: Response) => {
317
+ if (!config.federationEnabled) return res.status(404).json({ error: 'Federation disabled' });
318
+
319
+ if (!wantsActivityPub(req)) {
320
+ // Redirect to the frontend profile if not an AP request.
321
+ return res.redirect(`https://${domain}/@${getUsername(req)}`);
322
+ }
323
+
324
+ const username = getUsername(req);
325
+
326
+ try {
327
+ // Instance actor: a special server-level actor used for signed fetches. It
328
+ // has no Oxy user — serve it directly from the key material. The WebFinger
329
+ // router answers for the SAME username, and its `self` href is built from
330
+ // the same `urls.actor(INSTANCE_ACTOR_USERNAME)` call as the `id` below,
331
+ // because Mastodon rejects a signed fetch when the two disagree.
332
+ if (username === INSTANCE_ACTOR_USERNAME) {
333
+ const publicKey = await config.getPublicKey(INSTANCE_ACTOR_USERNAME);
334
+ const actorObject = {
335
+ '@context': AP_CONTEXT,
336
+ id: urls.actor(INSTANCE_ACTOR_USERNAME),
337
+ type: 'Application',
338
+ preferredUsername: INSTANCE_ACTOR_USERNAME,
339
+ name: domain,
340
+ summary: '',
341
+ url: `https://${domain}`,
342
+ inbox: urls.inbox(INSTANCE_ACTOR_USERNAME),
343
+ outbox: urls.outbox(INSTANCE_ACTOR_USERNAME),
344
+ endpoints: { sharedInbox: urls.sharedInbox() },
345
+ manuallyApprovesFollowers: false,
346
+ discoverable: false,
347
+ publicKey: {
348
+ id: publicKey.keyId,
349
+ owner: urls.actor(INSTANCE_ACTOR_USERNAME),
350
+ publicKeyPem: publicKey.publicKeyPem,
351
+ },
352
+ };
353
+ res.set('Content-Type', apContentType);
354
+ res.set('Cache-Control', 'max-age=1800');
355
+ return res.json(actorObject);
356
+ }
357
+
358
+ const user = await config.resolveUser(username);
359
+ if (!user) return res.status(404).json({ error: 'User not found' });
360
+
361
+ // Sharing OFF must be indistinguishable from a nonexistent user — same 404
362
+ // body, no separate error code. Derived from the already-resolved user.
363
+ if (!config.consent.isSharingEnabledFromUser(user)) {
364
+ return res.status(404).json({ error: 'User not found' });
365
+ }
366
+
367
+ const publicKey = await config.getPublicKey(username);
368
+
369
+ // The profile banner lives in the app's own per-user settings (not the Oxy
370
+ // user DTO), keyed by the resolved Oxy user id. Advertise it as the AP
371
+ // `image` (Mastodon header). Absent settings / banner cleanly omits it.
372
+ const userId = user._id || user.id;
373
+ const profileHeaderImage = userId ? await config.getBanner(String(userId)) : null;
374
+
375
+ // Canonical display name is owned by the Oxy API (`name.displayName`); fall
376
+ // back to the username only if the API omitted it, so `name` is never empty.
377
+ const displayName = user.name?.displayName || username;
378
+
379
+ // ONE actor builder — shared with the outbound `Update(Person)` broadcast — so
380
+ // a fetched actor and a pushed actor Update never drift. The route owns the
381
+ // top-level JSON-LD `@context` (the builder omits it).
382
+ const actorObject = config.buildLocalActorObject({
383
+ username,
384
+ displayName,
385
+ kind: user.kind,
386
+ bio: user.bio,
387
+ avatar: user.avatar,
388
+ profileHeaderImage,
389
+ publicKey,
390
+ createdAt: user.createdAt,
391
+ });
392
+
393
+ res.set('Content-Type', apContentType);
394
+ res.set('Cache-Control', 'max-age=1800');
395
+ return res.json({ '@context': AP_CONTEXT, ...actorObject });
396
+ } catch (err) {
397
+ logger.error('Actor endpoint error:', err);
398
+ return res.status(500).json({ error: 'Internal server error' });
399
+ }
400
+ });
401
+
402
+ // POST /ap/users/:username/inbox — User inbox
403
+ router.post('/users/:username/inbox', async (req: Request, res: Response) => {
404
+ if (!config.federationEnabled) return res.status(404).json({ error: 'Federation disabled' });
405
+
406
+ // Sharing OFF (or a bogus `:username`) must be indistinguishable from a
407
+ // nonexistent user — same 404 body. An Oxy OUTAGE ('unavailable') is
408
+ // deliberately NOT 404'd: this is a POST delivery, and a 4xx makes the remote
409
+ // server drop it permanently rather than retry, so availability wins over
410
+ // gating freshness — the activity is processed and any consent decision is
411
+ // re-checked downstream by the id-based (fail-open) gates.
412
+ const username = getUsername(req);
413
+ const sharingState = await config.consent.getSharingStateByUsername(username);
414
+ if (sharingState === 'disabled' || sharingState === 'unknown-user') {
415
+ return res.status(404).json({ error: 'User not found' });
416
+ }
417
+
418
+ return handleInbox(req, res);
419
+ });
420
+
421
+ // POST /ap/inbox — Shared inbox
422
+ router.post('/inbox', async (req: Request, res: Response) => {
423
+ if (!config.federationEnabled) return res.status(404).json({ error: 'Federation disabled' });
424
+ return handleInbox(req, res);
425
+ });
426
+
427
+ // GET /ap/users/:username/followers — Followers collection (Oxy graph: local + federated).
428
+ router.get('/users/:username/followers', (req: Request, res: Response) =>
429
+ serveFollowCollection(req, res, 'followers', urls.followers),
430
+ );
431
+
432
+ // GET /ap/users/:username/following — Following collection (Oxy graph: local + federated).
433
+ router.get('/users/:username/following', (req: Request, res: Response) =>
434
+ serveFollowCollection(req, res, 'following', urls.following),
435
+ );
436
+
437
+ return router;
438
+ }