@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,230 @@
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
+
20
+ import { getErrorMessage, getErrorStatus } from '@oxy.so/core';
21
+ import type { NormalizedExternalActor } from '../index';
22
+
23
+ /** The HTTP methods the service-scoped oxy-api transport is invoked with. */
24
+ export type ServiceRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
25
+
26
+ /** The service-scoped request transport oxy-api calls go through (`{ data }`-unwrapped). */
27
+ export type ServiceRequest = <T>(method: ServiceRequestMethod, path: string, body?: unknown) => Promise<T>;
28
+
29
+ /** Minimal logging sink the identity bridge writes to. */
30
+ export interface IdentityBridgeLogger {
31
+ info(message: string, meta?: unknown): void;
32
+ warn(message: string, meta?: unknown): void;
33
+ }
34
+
35
+ /** Adapters the identity bridge is built from. */
36
+ export interface IdentityBridgeConfig {
37
+ /** Service-scoped oxy-api request transport (unwraps the API's `{ data }` envelope). */
38
+ makeServiceRequest: ServiceRequest;
39
+ /**
40
+ * Called with the resolved Oxy user id after a successful `PUT /users/resolve`
41
+ * (before the banner mirror). For Mention: evict the warm user-summary cache.
42
+ */
43
+ onUserResolved?: (oxyUserId: string) => Promise<void> | void;
44
+ /**
45
+ * Mirror the actor's remote banner into a durable app-owned asset. Best-effort:
46
+ * MUST NOT throw (it handles its own errors); a failure never drops the resolved
47
+ * user. Absent ⇒ banners are not mirrored.
48
+ */
49
+ mirrorBanner?: (bannerUrl: string, oxyUserId: string, actorUri: string) => Promise<void>;
50
+ /** Diagnostics sink. */
51
+ logger: IdentityBridgeLogger;
52
+ }
53
+
54
+ /**
55
+ * Outcome discriminant of {@link IdentityBridge.reportActorGone}. NEVER thrown —
56
+ * both callers (the live 410 tombstone and the one-shot prune sweep) are
57
+ * fail-soft, so the transient/retryable case is surfaced as a value (`'failed'`).
58
+ *
59
+ * - `archived` — Oxy archived a previously-active identity (removed from search).
60
+ * - `already` — the identity was already archived (idempotent no-op, still 200).
61
+ * - `skipped` — nothing to report, or a PERMANENT client error (400/403/404/409).
62
+ * - `failed` — a genuinely transient failure (5xx, 408/429, network). Retryable.
63
+ */
64
+ export type ReportActorGoneOutcome = 'archived' | 'already' | 'skipped' | 'failed';
65
+
66
+ /**
67
+ * Outcome discriminant of {@link IdentityBridge.deleteActorIdentity}. NEVER thrown.
68
+ *
69
+ * - `deleted` — oxy-api hard-deleted a live Oxy identity (+ follow edges/blocks).
70
+ * - `absent` — the identity was already gone (200, `deleted:false`). Oxy side clean.
71
+ * - `skipped` — nothing to delete, or a PERMANENT client error (400/403/409).
72
+ * - `failed` — a genuinely transient failure (5xx, 408/429, network). Retryable.
73
+ */
74
+ export type DeleteActorIdentityOutcome = 'deleted' | 'absent' | 'skipped' | 'failed';
75
+
76
+ /** The `{ data }`-unwrapped body oxy-api returns for a 200 `POST /federation/actor-gone`. */
77
+ interface ActorGoneResponse {
78
+ oxyUserId: string;
79
+ accountStatus: 'archived';
80
+ alreadyArchived: boolean;
81
+ }
82
+
83
+ /** The `{ data }`-unwrapped body oxy-api returns for a 200 `POST /federation/actor-delete`. */
84
+ interface ActorDeleteResponse {
85
+ oxyUserId: string;
86
+ deleted: boolean;
87
+ followEdgesRemoved: number;
88
+ }
89
+
90
+ const ACTOR_GONE_PATH = '/federation/actor-gone';
91
+ const ACTOR_DELETE_PATH = '/federation/actor-delete';
92
+
93
+ /** The actor↔Oxy-user identity bridge. */
94
+ export interface IdentityBridge {
95
+ /**
96
+ * Resolve/mint the Oxy user a normalized external actor maps to, via
97
+ * `PUT /users/resolve` (service-scoped). Returns the resolved id, or `null` when
98
+ * Oxy is unreachable / returns no id (callers must then skip, never persisting an
99
+ * orphan). Mirrors the banner after resolution (best-effort).
100
+ */
101
+ resolveExternalUser(
102
+ actor: NormalizedExternalActor,
103
+ opts?: { forceAvatarRefresh?: boolean },
104
+ ): Promise<string | null>;
105
+ /** Ask oxy-api to ARCHIVE the identity of a permanently-gone actor (reversible). */
106
+ reportActorGone(oxyUserId: string): Promise<ReportActorGoneOutcome>;
107
+ /** Ask oxy-api to HARD-DELETE the identity of a permanently-gone actor (irreversible). */
108
+ deleteActorIdentity(oxyUserId: string): Promise<DeleteActorIdentityOutcome>;
109
+ }
110
+
111
+ /** True for a PERMANENT (non-retryable) 4xx: 400/403/404/409, but NOT 408/429. */
112
+ function isPermanentClientError(httpStatus: number | undefined): boolean {
113
+ return (
114
+ httpStatus !== undefined &&
115
+ httpStatus >= 400 &&
116
+ httpStatus < 500 &&
117
+ httpStatus !== 408 &&
118
+ httpStatus !== 429
119
+ );
120
+ }
121
+
122
+ /** Build the network-neutral identity bridge from an app's adapters. */
123
+ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBridge {
124
+ return {
125
+ async resolveExternalUser(actor, opts = {}): Promise<string | null> {
126
+ const forceAvatarRefresh = opts.forceAvatarRefresh ?? false;
127
+ try {
128
+ // The connector owns deriving the canonical `local@domain` username and the
129
+ // instance domain for its protocol, so this bridge stays protocol-agnostic:
130
+ // it never has to guess a domain out of a bare atproto handle or a hostless
131
+ // DID. oxy-api binds the two (username domain must equal `domain`).
132
+ const oxyUser = await config.makeServiceRequest<{ _id?: string; id?: string } | null>(
133
+ 'PUT',
134
+ '/users/resolve',
135
+ {
136
+ type: 'federated',
137
+ username: actor.federatedUsername,
138
+ actorUri: actor.externalId,
139
+ domain: actor.instanceDomain,
140
+ displayName: actor.displayName,
141
+ avatar: actor.avatarUrl,
142
+ bio: actor.bio,
143
+ // On refresh, tell Oxy to re-download and replace the avatar even if it
144
+ // already stored a file ID. Coordinated with oxy-api's
145
+ // `refresh` / `forceAvatarRefresh` flag on PUT /users/resolve.
146
+ refresh: forceAvatarRefresh,
147
+ forceAvatarRefresh,
148
+ },
149
+ );
150
+ const oxyId = String(oxyUser?._id || oxyUser?.id || '');
151
+ if (!oxyId) return null;
152
+
153
+ // A re-resolve can refresh the federated actor's display name / avatar in
154
+ // Oxy. Let the app evict any warm cache so the next read is fresh.
155
+ if (config.onUserResolved) {
156
+ await config.onUserResolved(oxyId);
157
+ }
158
+
159
+ if (actor.bannerUrl && config.mirrorBanner) {
160
+ // Best-effort on the live path: the mirror handles its own failures.
161
+ await config.mirrorBanner(actor.bannerUrl, oxyId, actor.externalId);
162
+ }
163
+
164
+ return oxyId;
165
+ } catch (resolveErr) {
166
+ config.logger.warn(`Failed to resolve Oxy user for ${actor.externalId}:`, resolveErr);
167
+ return null;
168
+ }
169
+ },
170
+
171
+ async reportActorGone(oxyUserId): Promise<ReportActorGoneOutcome> {
172
+ const id = oxyUserId.trim();
173
+ if (!id) return 'skipped';
174
+
175
+ try {
176
+ const data = await config.makeServiceRequest<ActorGoneResponse>('POST', ACTOR_GONE_PATH, {
177
+ oxyUserId: id,
178
+ });
179
+ const alreadyArchived = data?.alreadyArchived === true;
180
+ config.logger.info(`[Federation] oxy-api archived gone actor ${id}`, { alreadyArchived });
181
+ return alreadyArchived ? 'already' : 'archived';
182
+ } catch (error) {
183
+ const httpStatus = getErrorStatus(error);
184
+ const reason = getErrorMessage(error);
185
+ if (isPermanentClientError(httpStatus)) {
186
+ config.logger.warn(`[Federation] actor-gone report for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
187
+ reason,
188
+ });
189
+ return 'skipped';
190
+ }
191
+ config.logger.warn(`[Federation] actor-gone report for ${id} failed transiently; leaving for retry`, {
192
+ status: httpStatus,
193
+ reason,
194
+ });
195
+ return 'failed';
196
+ }
197
+ },
198
+
199
+ async deleteActorIdentity(oxyUserId): Promise<DeleteActorIdentityOutcome> {
200
+ const id = oxyUserId.trim();
201
+ if (!id) return 'skipped';
202
+
203
+ try {
204
+ const data = await config.makeServiceRequest<ActorDeleteResponse>('POST', ACTOR_DELETE_PATH, {
205
+ oxyUserId: id,
206
+ });
207
+ const deleted = data?.deleted === true;
208
+ config.logger.info(
209
+ `[Federation] oxy-api ${deleted ? 'hard-deleted' : 'found no'} identity for gone actor ${id}`,
210
+ { followEdgesRemoved: data?.followEdgesRemoved ?? 0 },
211
+ );
212
+ return deleted ? 'deleted' : 'absent';
213
+ } catch (error) {
214
+ const httpStatus = getErrorStatus(error);
215
+ const reason = getErrorMessage(error);
216
+ if (isPermanentClientError(httpStatus)) {
217
+ config.logger.warn(`[Federation] actor-delete for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
218
+ reason,
219
+ });
220
+ return 'skipped';
221
+ }
222
+ config.logger.warn(`[Federation] actor-delete for ${id} failed transiently; leaving for retry`, {
223
+ status: httpStatus,
224
+ reason,
225
+ });
226
+ return 'failed';
227
+ }
228
+ },
229
+ };
230
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Inbound ActivityPub dispatch + the follow-protocol handlers.
3
+ *
4
+ * The engine owns the DISPATCHER (validate the untrusted activity, switch on its
5
+ * type) and the FOLLOW-PROTOCOL verbs — Follow / Accept / Undo(Follow) / Reject —
6
+ * because those are identical across every Oxy app: they bridge a federated follow
7
+ * edge into the Oxy graph (via the identity adapter), record the AP-side follow
8
+ * row (via the store adapter), and send the Accept back (via the delivery
9
+ * service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
10
+ * non-follow Undo) is handed to the app-registered
11
+ * {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
12
+ * post/engagement handlers live. The consent gate + notification side effects are
13
+ * injected so the engine holds no app knowledge.
14
+ *
15
+ * Extracted behaviour-identically from Mention's former `InboxProcessingService`
16
+ * dispatcher + `handleIncomingFollow` / `handleUndo(Follow)` / `handleAccept` /
17
+ * `handleReject`.
18
+ */
19
+
20
+ import { normalizeActorUsername } from '../urls';
21
+
22
+ /**
23
+ * Thrown when a federated follow is about to be bridged but the FOLLOWER actor
24
+ * has not yet resolved to an Oxy user (`oxyUserId` missing) — e.g. Oxy was
25
+ * unreachable when the actor was fetched. A federated follow MUST become a real
26
+ * Oxy edge, never a ghost, so the whole inbound activity is DEFERRED rather than
27
+ * bridged half-way:
28
+ *
29
+ * - in the BullMQ inbox worker, throwing fails the job, which retries with
30
+ * bounded exponential backoff; a later attempt (Oxy reachable) resolves the
31
+ * actor and bridges the follow. A permanently-unresolvable actor exhausts the
32
+ * attempts and the activity is dropped — never a ghost edge.
33
+ * - in the inline (no-Redis) fallback, it surfaces as a 500 from the inbox
34
+ * endpoint, so the remote re-delivers per ActivityPub.
35
+ */
36
+ export class ActorResolutionPendingError extends Error {
37
+ /** The remote actor URI whose Oxy resolution is still pending. */
38
+ readonly actorUri: string;
39
+
40
+ constructor(actorUri: string, context?: string) {
41
+ super(
42
+ `Actor ${actorUri} is not yet resolved to an Oxy user${context ? ` (${context})` : ''}; deferring inbound activity`,
43
+ );
44
+ this.name = 'ActorResolutionPendingError';
45
+ this.actorUri = actorUri;
46
+ }
47
+ }
48
+
49
+ /** Minimal logging sink the inbound dispatcher writes to. */
50
+ export interface InboundDispatcherLogger {
51
+ debug(message: string): void;
52
+ info(message: string): void;
53
+ warn(message: string, detail?: unknown): void;
54
+ }
55
+
56
+ /**
57
+ * The verdict of validating an untrusted inbound activity: its primary type, or a
58
+ * compact failure summary. The app owns the validation (its zod schemas); the
59
+ * engine owns the drop-with-warn behaviour so every app logs identically.
60
+ */
61
+ export type InboundActivityValidation =
62
+ | { ok: true; type: string }
63
+ | { ok: false; summary: string };
64
+
65
+ /** The Oxy-user fields the engine reads off a resolved local user (Follow target). */
66
+ export interface InboundLocalUser {
67
+ _id?: string | null;
68
+ id?: string | null;
69
+ }
70
+
71
+ /** The actor↔Oxy-user identity bridge the follow verbs use. */
72
+ export interface InboundIdentity {
73
+ /** Resolve a local username to its Oxy user (the Follow target). Null when unknown. */
74
+ resolveUserByUsername(username: string): Promise<InboundLocalUser | null>;
75
+ /** Create the Oxy follow edge (`POST /federation/follow`). Throws on transport failure (retry). */
76
+ bridgeFollow(followerOxyUserId: string, localUserId: string): Promise<void>;
77
+ /** Remove the Oxy follow edge. Throws on transport failure (retry). */
78
+ bridgeUnfollow(followerOxyUserId: string, localUserId: string): Promise<void>;
79
+ }
80
+
81
+ /** The fediverse-sharing consent gate for the inbound Follow. */
82
+ export interface InboundConsent {
83
+ /** Sync read off an already-resolved user object (absent flag ⇒ enabled). */
84
+ isSharingEnabledFromUser(user: InboundLocalUser): boolean;
85
+ }
86
+
87
+ /** The actor resolver subset the inbound follow uses (resolve + require an Oxy id). */
88
+ export interface InboundActorResolver {
89
+ /** Resolve/mint the follower actor and its Oxy user (`getOrFetchActor`). */
90
+ getOrFetchActor(actorUri: string): Promise<{ oxyUserId?: string | null } | null>;
91
+ }
92
+
93
+ /** Bring-your-own-store: the AP follow records + actor cache reads the follow verbs need. */
94
+ export interface InboundFollowStore {
95
+ /** `handleIncomingFollow`: upsert the accepted inbound follow row. */
96
+ upsertInboundAccepted(localUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
97
+ /** `handleUndo(Follow)`: the inbound follow row (scoped by localUserId when known). */
98
+ findInboundFollow(remoteActorUri: string, localUserId?: string): Promise<{ _id: unknown; localUserId: string } | null>;
99
+ /** `handleUndo(Follow)`: delete a follow row by id. */
100
+ deleteFollowById(id: unknown): Promise<void>;
101
+ /** `handleUndo(Follow)`: the follower actor's cached Oxy user id (for `bridgeUnfollow`). */
102
+ findActorOxyUserId(uri: string): Promise<string | null | undefined>;
103
+ /** `handleAccept`: mark the matching outbound-pending follow accepted BY its activity id. Returns whether a row changed. */
104
+ markOutboundAcceptedByActivityId(remoteActorUri: string, activityId: string): Promise<boolean>;
105
+ /** `handleAccept`: mark ANY outbound-pending follow for this actor accepted. Returns whether a row changed. */
106
+ markOutboundAcceptedAnyPending(remoteActorUri: string): Promise<boolean>;
107
+ /** `handleReject`: mark the matching outbound-pending follow rejected. */
108
+ markOutboundRejected(remoteActorUri: string, activityId?: string): Promise<void>;
109
+ }
110
+
111
+ /** The delivery subset the inbound Follow uses (send the Accept back). */
112
+ export interface InboundDelivery {
113
+ sendAccept(
114
+ localOxyUserId: string,
115
+ localUsername: string,
116
+ followActivityId: string,
117
+ remoteActorUri: string,
118
+ ): Promise<void>;
119
+ }
120
+
121
+ /** Adapters + hooks an {@link InboundDispatcher} is built from. */
122
+ export interface InboundDispatcherConfig {
123
+ /**
124
+ * The app's per-instance domain policy (`DomainPolicy.isBlockedDomain`) — our own
125
+ * ActivityPub domains, the Oxy identity apex, and every explicitly suspended
126
+ * instance. Applied to the host of the VERIFIED origin actor before an inbound
127
+ * activity is dispatched, so a suspended instance can create nothing here.
128
+ *
129
+ * REQUIRED, deliberately: an app that forgets to wire it would silently federate
130
+ * with every instance it has ever cached, which is exactly the failure this gate
131
+ * exists to prevent. A missing policy must be a compile error, not a quiet hole.
132
+ */
133
+ isBlockedDomain(host: string): boolean;
134
+ /** Validate + extract the primary type of an untrusted inbound activity (app's zod schemas). */
135
+ validateActivity(activity: Record<string, unknown>): InboundActivityValidation;
136
+ /** The actor↔Oxy-user identity bridge. */
137
+ identity: InboundIdentity;
138
+ /** The fediverse-sharing consent gate. */
139
+ consent: InboundConsent;
140
+ /** The actor resolver (resolve the follower actor). */
141
+ actorResolver: InboundActorResolver;
142
+ /** The AP follow-record store. */
143
+ follows: InboundFollowStore;
144
+ /** The delivery service (send the Accept). */
145
+ delivery: InboundDelivery;
146
+ /**
147
+ * Best-effort: notify the local user of a newly-accepted inbound follow.
148
+ * NEVER throws (it handles its own errors); a failure must not fail (and thus
149
+ * retry) the inbox activity. Absent ⇒ no notification.
150
+ */
151
+ onInboundFollowAccepted?(localUserId: string, followerOxyUserId: string, actorUri: string): Promise<void>;
152
+ /**
153
+ * Best-effort: backfill the newly-followed remote actor's recent posts after an
154
+ * outbound Follow was Accepted. NEVER throws. Absent ⇒ no backfill.
155
+ */
156
+ onOutboundFollowAccepted?(actorUri: string): Promise<void>;
157
+ /**
158
+ * Handle a CONTENT activity the engine does not own — Create / Announce / Like /
159
+ * Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
160
+ */
161
+ onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
162
+ /** Diagnostics sink. */
163
+ logger: InboundDispatcherLogger;
164
+ }
165
+
166
+ /** The inbound-activity dispatcher. */
167
+ export interface InboundDispatcher {
168
+ /** Process one already-actor-verified inbound activity. */
169
+ processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
170
+ }
171
+
172
+ /**
173
+ * The lowercased host of an actor URI, or null when it is not a parseable absolute
174
+ * URL. Callers treat null as blocked: an origin whose host cannot be determined
175
+ * cannot be checked against the domain policy, so it fails closed.
176
+ */
177
+ function actorUriHost(actorUri: string): string | null {
178
+ try {
179
+ return new URL(actorUri).hostname.toLowerCase();
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /** Read the `object`'s referenced actor/target uri (a string, or an embedded `{ id }`). */
186
+ function objectTargetUri(object: unknown): string | undefined {
187
+ if (typeof object === 'string') return object;
188
+ if (object && typeof object === 'object') {
189
+ const id = (object as { id?: unknown }).id;
190
+ if (typeof id === 'string') return id;
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ /** Build the inbound-activity dispatcher from an app's adapters + content handlers. */
196
+ export function createInboundDispatcher(config: InboundDispatcherConfig): InboundDispatcher {
197
+ const { logger } = config;
198
+
199
+ async function handleIncomingFollow(activity: Record<string, unknown>, actorUri: string): Promise<void> {
200
+ const targetActorUri = objectTargetUri(activity.object);
201
+ if (!targetActorUri) return;
202
+
203
+ // Extract username from our actor URL
204
+ const match = targetActorUri.match(/\/ap\/users\/([^/]+)$/);
205
+ if (!match) return;
206
+ const username = normalizeActorUsername(match[1]);
207
+
208
+ // Resolve the Oxy user to get a real user ID
209
+ const user = await config.identity.resolveUserByUsername(username);
210
+ if (!user) {
211
+ logger.warn(`Incoming follow for unknown user ${username} from ${actorUri}`);
212
+ return;
213
+ }
214
+ const localUserId = String(user._id || user.id);
215
+
216
+ // The target user may have turned fediverse sharing off — drop the Follow
217
+ // silently (no bridge, no Accept, no Reject). A Reject is unverifiable
218
+ // against a 404'd actor and would reveal the account exists, so this must
219
+ // look identical to a Follow sent to an unknown user. Gated here, BEFORE
220
+ // the follower actor is fetched/resolved, so an OFF user never triggers any
221
+ // of the bridge/Accept/notification side effects below.
222
+ if (!config.consent.isSharingEnabledFromUser(user)) {
223
+ logger.debug(`[Federation] inbound follow for ${username} dropped — sharing off`);
224
+ return;
225
+ }
226
+
227
+ // Resolve the follower actor and REQUIRE its Oxy user id: a fediverse
228
+ // follower must become a real Oxy edge, never a ghost. When the actor is
229
+ // missing or not yet resolved to an Oxy user (Oxy was unreachable when it was
230
+ // fetched), throw `ActorResolutionPendingError` so the BullMQ inbox job
231
+ // retries with backoff and bridges the follow on a later attempt.
232
+ const actor = await config.actorResolver.getOrFetchActor(actorUri);
233
+ const followerOxyUserId = actor?.oxyUserId;
234
+ if (!followerOxyUserId) {
235
+ throw new ActorResolutionPendingError(actorUri, `Follow ${String(activity.id)}`);
236
+ }
237
+
238
+ // A self-follow (the follower resolves to the same local user) is meaningless
239
+ // in the Oxy graph — skip before touching any state or delivering an Accept.
240
+ if (followerOxyUserId === localUserId) {
241
+ logger.debug(`[Federation] ignoring self-follow from ${actorUri} to ${username}`);
242
+ return;
243
+ }
244
+
245
+ // Create the Oxy follow edge BEFORE sending Accept so a retry never spams
246
+ // Accepts: the bridge is idempotent (safe to re-run), but an Accept delivered
247
+ // before the edge was committed could be re-sent on every retry. On failure
248
+ // the bridge throws, failing the job so the whole sequence retries.
249
+ await config.identity.bridgeFollow(followerOxyUserId, localUserId);
250
+
251
+ await config.follows.upsertInboundAccepted(localUserId, actorUri, String(activity.id));
252
+
253
+ // Send Accept back so the remote server knows the follow succeeded
254
+ await config.delivery.sendAccept(localUserId, username, String(activity.id), actorUri);
255
+
256
+ // Fail-soft: the Oxy edge is already committed, so a notification failure must
257
+ // never fail (and thus retry) the follow.
258
+ if (config.onInboundFollowAccepted) {
259
+ await config.onInboundFollowAccepted(localUserId, followerOxyUserId, actorUri);
260
+ }
261
+
262
+ logger.info(`Accepted follow from ${actorUri} to ${username}`);
263
+ }
264
+
265
+ async function handleUndoFollow(object: Record<string, unknown>, actorUri: string): Promise<void> {
266
+ const targetActorUri = objectTargetUri(object.object);
267
+ const match = targetActorUri?.match(/\/ap\/users\/([^/]+)$/);
268
+ let localUserId: string | undefined;
269
+ if (match) {
270
+ const user = await config.identity.resolveUserByUsername(normalizeActorUsername(match[1]));
271
+ if (user) localUserId = String(user._id || user.id);
272
+ }
273
+
274
+ // Idempotency: locate the follow row FIRST. Absent → this Undo was already
275
+ // processed (a redelivery), so there is nothing to tear down — return.
276
+ const follow = await config.follows.findInboundFollow(actorUri, localUserId);
277
+ if (!follow) {
278
+ logger.debug(`Undo follow from ${actorUri}: no matching row (already processed)`);
279
+ return;
280
+ }
281
+
282
+ // Remove the Oxy follow edge BEFORE deleting the local row, so a transient
283
+ // bridge failure retries with the row still present. The edge can only exist
284
+ // when the follower actor resolved to an Oxy user; without an `oxyUserId` no
285
+ // edge was ever created, so there is nothing to remove. THROW on transient
286
+ // bridge failure (job retry); the bridge is idempotent.
287
+ const followerOxyUserId = await config.follows.findActorOxyUserId(actorUri);
288
+ if (followerOxyUserId) {
289
+ await config.identity.bridgeUnfollow(followerOxyUserId, follow.localUserId);
290
+ }
291
+
292
+ await config.follows.deleteFollowById(follow._id);
293
+ logger.debug(`Undo follow from ${actorUri}`);
294
+ }
295
+
296
+ async function handleUndo(activity: Record<string, unknown>, actorUri: string): Promise<void> {
297
+ const object = activity.object;
298
+ if (!object) return;
299
+
300
+ const objectType = typeof object === 'string' ? null : (object as { type?: unknown }).type;
301
+ if (objectType === 'Follow') {
302
+ await handleUndoFollow(object as Record<string, unknown>, actorUri);
303
+ } else {
304
+ // Undo(Like) / Undo(Announce) — content teardown. Hand the WHOLE Undo
305
+ // activity to the app (it re-inspects the embedded object type).
306
+ await config.onContentActivity(activity, actorUri);
307
+ }
308
+ }
309
+
310
+ async function handleAccept(activity: Record<string, unknown>, actorUri: string): Promise<void> {
311
+ const object = activity.object;
312
+ if (!object) return;
313
+
314
+ let updated = false;
315
+
316
+ if (typeof object === 'string') {
317
+ // Remote sent Accept with a string reference (the Follow activity ID).
318
+ // Try matching by activityId first, fall back to any pending follow.
319
+ updated = await config.follows.markOutboundAcceptedByActivityId(actorUri, object);
320
+ if (!updated) {
321
+ updated = await config.follows.markOutboundAcceptedAnyPending(actorUri);
322
+ }
323
+ } else if ((object as { type?: unknown }).type === 'Follow') {
324
+ const followActivityId = (object as { id?: unknown }).id;
325
+ updated = typeof followActivityId === 'string' && followActivityId.length > 0
326
+ ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
327
+ : await config.follows.markOutboundAcceptedAnyPending(actorUri);
328
+ }
329
+
330
+ if (updated) {
331
+ logger.debug(`Follow accepted by ${actorUri}`);
332
+ // Fire-and-forget: backfill the newly followed actor's recent posts.
333
+ if (config.onOutboundFollowAccepted) {
334
+ await config.onOutboundFollowAccepted(actorUri);
335
+ }
336
+ }
337
+ }
338
+
339
+ async function handleReject(activity: Record<string, unknown>, actorUri: string): Promise<void> {
340
+ const object = activity.object;
341
+ if (!object) return;
342
+
343
+ const objectType = typeof object === 'string' ? null : (object as { type?: unknown }).type;
344
+ if (objectType === 'Follow') {
345
+ const followActivityId = typeof object === 'object' ? (object as { id?: unknown }).id : undefined;
346
+ await config.follows.markOutboundRejected(
347
+ actorUri,
348
+ typeof followActivityId === 'string' ? followActivityId : undefined,
349
+ );
350
+ logger.debug(`Follow rejected by ${actorUri}`);
351
+ }
352
+ }
353
+
354
+ async function processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void> {
355
+ // Instance domain policy, FIRST — before the payload is even parsed.
356
+ //
357
+ // This is the single chokepoint for inbound federation: every transport (the
358
+ // inbox route's inline path, the BullMQ inbox worker replaying a queued job,
359
+ // and any direct connector call) converges here, and every verb — Follow,
360
+ // Accept, Undo, Reject, and the app's content verbs — is dispatched below it.
361
+ // So one check here suspends an instance completely: no posts, no actors, no
362
+ // follows, no boosts, no notifications.
363
+ //
364
+ // It is keyed on `verifiedActorUri`, the origin the HTTP signature actually
365
+ // proved, which is also the identity every handler downstream trusts — so
366
+ // there is no second, weaker identity a hostile payload could be routed under.
367
+ const originHost = actorUriHost(verifiedActorUri);
368
+ if (originHost === null || config.isBlockedDomain(originHost)) {
369
+ logger.warn(
370
+ `[Federation] dropping inbound activity from blocked origin ${verifiedActorUri} (host=${originHost ?? 'unparseable'})`,
371
+ );
372
+ return;
373
+ }
374
+
375
+ // Inbound JSON arrives from arbitrary, UNTRUSTED remote servers. Validate the
376
+ // whole activity BEFORE any handler reads it. The validation never throws; a
377
+ // malformed or hostile payload is rejected cleanly here.
378
+ const validation = config.validateActivity(activity);
379
+ if (!validation.ok) {
380
+ const rawType =
381
+ typeof activity?.type === 'string'
382
+ ? activity.type
383
+ : Array.isArray(activity?.type)
384
+ ? activity.type.join(',')
385
+ : 'unknown';
386
+ const rawId = typeof activity?.id === 'string' ? activity.id : 'unknown';
387
+ logger.warn(
388
+ `[Federation] dropping invalid inbound activity from ${verifiedActorUri} (type=${rawType}, id=${rawId}): ${validation.summary}`,
389
+ );
390
+ return;
391
+ }
392
+
393
+ switch (validation.type) {
394
+ case 'Follow':
395
+ await handleIncomingFollow(activity, verifiedActorUri);
396
+ break;
397
+ case 'Undo':
398
+ await handleUndo(activity, verifiedActorUri);
399
+ break;
400
+ case 'Accept':
401
+ await handleAccept(activity, verifiedActorUri);
402
+ break;
403
+ case 'Reject':
404
+ await handleReject(activity, verifiedActorUri);
405
+ break;
406
+ // Content verbs — the app owns these (posts, engagement, actor profile edits).
407
+ case 'Create':
408
+ case 'Delete':
409
+ case 'Like':
410
+ case 'Announce':
411
+ case 'Update':
412
+ await config.onContentActivity(activity, verifiedActorUri);
413
+ break;
414
+ default:
415
+ logger.debug(`Unhandled activity type: ${validation.type}`);
416
+ }
417
+ }
418
+
419
+ return { processInboxActivity };
420
+ }