@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,287 @@
1
+ /**
2
+ * Resolution, caching and refresh of remote ActivityPub actors.
3
+ *
4
+ * Extracted behaviour-identically from Mention's `ActorService`. The engine owns
5
+ * the PROTOCOL — webfinger resolution, the signed actor fetch, the redirect /
6
+ * WebFinger fallback, the 410-Gone tombstone, the self-consistency + same-origin
7
+ * guards, and the staleness/refresh policy. Everything app-specific is injected:
8
+ *
9
+ * - the FederatedActor CACHE lives in the app DB, reached through a
10
+ * {@link FederatedActorStore} adapter ("bring your own store" — no data move),
11
+ * - the actor↔Oxy-user bridge is the injected {@link ActorResolverIdentity}
12
+ * (`PUT /users/resolve` + actor-gone archive),
13
+ * - the signed AP fetch + the SSRF-safe WebFinger fetch are injected transports,
14
+ * - remote-text normalization is an injected {@link ActorTextAdapter} (the app's
15
+ * canonical normalizer + sanitizer), so the engine ships no HTML deps.
16
+ *
17
+ * The resolver is generic over the app's stored actor record shape (`TActor`,
18
+ * e.g. Mention's `IFederatedActor`) so callers keep full typing on the returned
19
+ * document.
20
+ */
21
+ import { type DeriveNetworkIdentity } from '../networkIdentity';
22
+ import type { NormalizedExternalActor } from '../index';
23
+ import type { SignedFetch } from './signedFetch';
24
+ import type { ReportActorGoneOutcome } from './identityBridge';
25
+ /** The minimal fields the resolver reads off / writes to a stored actor record. */
26
+ export interface FederatedActorRecordBase {
27
+ _id?: unknown;
28
+ uri: string;
29
+ acct?: string;
30
+ oxyUserId?: string | null;
31
+ avatarUrl?: string;
32
+ headerUrl?: string;
33
+ publicKeyPem?: string;
34
+ lastFetchedAt?: Date | null;
35
+ }
36
+ /** A verified profile field (PropertyValue) stored on the actor cache. */
37
+ export interface FederatedActorField {
38
+ name: string;
39
+ value: string;
40
+ verifiedAt?: Date;
41
+ }
42
+ /** The full write shape the resolver upserts into the actor cache. */
43
+ export interface FederatedActorUpsert {
44
+ protocol: 'activitypub';
45
+ uri: string;
46
+ username: string;
47
+ domain: string;
48
+ acct: string;
49
+ summary: string;
50
+ avatarUrl?: string;
51
+ headerUrl?: string;
52
+ inboxUrl?: string;
53
+ outboxUrl?: string;
54
+ sharedInboxUrl?: string;
55
+ followersUrl?: string;
56
+ followingUrl?: string;
57
+ publicKeyPem?: string;
58
+ publicKeyId?: string;
59
+ type: string;
60
+ manuallyApprovesFollowers: boolean;
61
+ discoverable: boolean;
62
+ memorial: boolean;
63
+ suspended: boolean;
64
+ fields: FederatedActorField[];
65
+ featuredUrl?: string;
66
+ featuredTagsUrl?: string;
67
+ alsoKnownAs?: string[];
68
+ /**
69
+ * The `<handle>@<network-domain>` identity this actor was re-labelled onto, when
70
+ * it came from a bridge; absent for the ordinary actor whose identity is simply
71
+ * its acct.
72
+ *
73
+ * Persisted rather than re-derived on demand because it is the key two rows are
74
+ * the SAME PERSON on: the same X account mirrored by two different bridges
75
+ * produces two actor rows with different URIs and different accts, and this is
76
+ * the only field on which they match. An app that de-duplicates bridged
77
+ * identities queries it; one that does not can ignore it.
78
+ */
79
+ networkAcct?: string;
80
+ remoteCreatedAt?: Date;
81
+ followersCount: number;
82
+ followingCount: number;
83
+ postsCount: number;
84
+ lastFetchedAt: Date;
85
+ }
86
+ /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
87
+ export interface FederatedActorStore<TActor extends FederatedActorRecordBase> {
88
+ /** Look up a cached actor by its protocol URI. */
89
+ findActorByUri(uri: string): Promise<TActor | null>;
90
+ /** Upsert (create-or-update) the actor cache row keyed by `uri`. */
91
+ upsertActor(uri: string, update: FederatedActorUpsert): Promise<TActor | null>;
92
+ /** Look up a cached actor by its `publicKey.id` (HTTP-signature key resolution). */
93
+ findActorByPublicKeyId(keyId: string): Promise<Pick<TActor, 'uri' | 'publicKeyPem'> | null>;
94
+ /** Stamp the resolved Oxy user id onto an actor row (identified by its `_id`). */
95
+ setActorOxyUserId(actorId: unknown, oxyUserId: string): Promise<void>;
96
+ /**
97
+ * Tombstone a permanently-gone actor (mark it suspended) and return its linked
98
+ * Oxy user id (or null when no row matched).
99
+ */
100
+ tombstoneActor(uri: string): Promise<{
101
+ oxyUserId?: string | null;
102
+ } | null>;
103
+ }
104
+ /** The identity-bridge subset the actor resolver depends on. */
105
+ export interface ActorResolverIdentity {
106
+ resolveExternalUser(actor: NormalizedExternalActor, opts?: {
107
+ forceAvatarRefresh?: boolean;
108
+ }): Promise<string | null>;
109
+ reportActorGone(oxyUserId: string): Promise<ReportActorGoneOutcome>;
110
+ }
111
+ /**
112
+ * App-supplied normalization of remote actor text. The engine owns WHICH fields
113
+ * to read and the order; the app owns HOW to normalize (its canonical whitespace
114
+ * normalizer + HTML sanitizer), so the engine ships no HTML/entity dependency.
115
+ */
116
+ export interface ActorTextAdapter {
117
+ /** One-line field (preferredUsername / name / PropertyValue name); '' for non-strings. */
118
+ inlineField(value: unknown): string;
119
+ /** Entity-decode + inline-normalize a display name. */
120
+ inlineDisplayName(raw: string): string;
121
+ /** Sanitize (safe inline markup only) + inline-normalize a PropertyValue html value. */
122
+ sanitizeFieldValue(html: string): string;
123
+ /** Multiline HTML → plain text (the actor bio/summary). */
124
+ htmlToPlainText(html: string): string;
125
+ /**
126
+ * Qualify the bare `@handle`s an actor wrote in its own bio with the network
127
+ * they belong to — `@openai` on an X-relabelled actor means `@openai@x.com`.
128
+ *
129
+ * A handle is only meaningful beside the network it was written on, and that
130
+ * context is exactly what is lost when the text crosses over: copied verbatim,
131
+ * `@openai` reads on the receiving server as a LOCAL name, pointing readers at
132
+ * whoever holds it there.
133
+ *
134
+ * OPTIONAL, and the engine does not care whether an app supplies it: the rule
135
+ * for what may be a handle is the app's (Mention scans with the same entity
136
+ * scanner its composer and renderer use, so a URL's `@handle`, an email and an
137
+ * already-qualified handle are all left alone by construction). An app that
138
+ * omits it gets the previous behaviour exactly.
139
+ *
140
+ * Applied ONCE, where the bio is settled — so the stored actor row and the Oxy
141
+ * profile cannot disagree, and no renderer is left to re-derive it.
142
+ */
143
+ qualifyHandles?(text: string, instanceDomain: string): string;
144
+ }
145
+ /** A parsed WebFinger JRD (only the `links` we read). */
146
+ export interface WebFingerJrd {
147
+ links?: Array<{
148
+ rel?: string;
149
+ type?: string;
150
+ href?: string;
151
+ }>;
152
+ }
153
+ /**
154
+ * SSRF-safe bounded WebFinger fetch: GET the JRD URL and return the parsed JSON,
155
+ * or `null` on a non-2xx response. MAY throw on a network / parse / size-limit
156
+ * failure — the resolver catches it and treats the resolution as failed.
157
+ */
158
+ export type WebFingerFetch = (url: string) => Promise<WebFingerJrd | null>;
159
+ /** Minimal logging sink the actor resolver writes to. */
160
+ export interface ActorResolverLogger {
161
+ info(message: string): void;
162
+ warn(message: string, detail?: unknown): void;
163
+ }
164
+ /** Adapters + config an {@link ActorResolver} is built from. */
165
+ export interface ActorResolverConfig<TActor extends FederatedActorRecordBase> {
166
+ /** Whether federation is enabled (gates background refreshes). */
167
+ federationEnabled: boolean;
168
+ /** Signed AP GET (actor + collection-count fetches). */
169
+ signedFetch: SignedFetch;
170
+ /** SSRF-safe bounded WebFinger fetch. */
171
+ fetchWebFinger: WebFingerFetch;
172
+ /** Per-instance blocked-domain check (own domains + identity apex + configured blocks). */
173
+ isBlockedDomain: (domain: string) => boolean;
174
+ /** Canonicalize a fediverse acct (`user@domain`), or undefined when invalid. */
175
+ normalizeFederatedAcct: (acct: string | undefined) => string | undefined;
176
+ /** Extract the domain from a canonical acct. */
177
+ domainFromAcct: (acct: string) => string | undefined;
178
+ /** Recursively find the first absolute http(s) URL in a value (icon/image). */
179
+ firstStringUrl: (value: unknown) => string | undefined;
180
+ /**
181
+ * Optional re-labelling of a bridged actor onto its real network. Absent means
182
+ * every actor keeps the identity of the host it was fetched from.
183
+ */
184
+ deriveNetworkIdentity?: DeriveNetworkIdentity;
185
+ /** The app's actor cache store. */
186
+ store: FederatedActorStore<TActor>;
187
+ /** The actor↔Oxy-user identity bridge. */
188
+ identity: ActorResolverIdentity;
189
+ /** Remote-text normalization. */
190
+ text: ActorTextAdapter;
191
+ /** Diagnostics sink. */
192
+ logger: ActorResolverLogger;
193
+ }
194
+ /**
195
+ * Resolution, caching and refresh of remote ActivityPub actors, over app-provided
196
+ * storage + identity + transports. A class so that internal cross-calls dispatch
197
+ * through the instance (e.g. `fetchRemoteActor` → `this.tombstoneGoneActor`),
198
+ * which keeps them spy-able and overridable in tests.
199
+ */
200
+ export declare class ActorResolver<TActor extends FederatedActorRecordBase> {
201
+ private readonly config;
202
+ /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
203
+ private readonly inFlightActorRefreshes;
204
+ constructor(config: ActorResolverConfig<TActor>);
205
+ /**
206
+ * Whether an actor URI's host is refused by the instance domain policy. An
207
+ * unparseable URI has no host to check, so it is refused too — the policy is a
208
+ * safety gate and fails closed rather than letting a malformed URI slip past it.
209
+ */
210
+ private isBlockedActorUri;
211
+ private acctMatchesActorHost;
212
+ /**
213
+ * Resolve a WebFinger acct to an ActivityPub actor URI.
214
+ * @param acct - e.g. "alice@mastodon.social" or "@alice@mastodon.social"
215
+ */
216
+ resolveWebFinger(acct: string): Promise<string | null>;
217
+ /**
218
+ * Fetch and store/update a remote ActivityPub actor by URI.
219
+ *
220
+ * @param actorUri - the remote actor URI to fetch.
221
+ * @param forceAvatarRefresh - when true, tell Oxy's `PUT /users/resolve` to
222
+ * re-download and replace the federated avatar even if it already has a stored
223
+ * file ID. Pass `true` from refresh paths and `false` for first-time creation.
224
+ */
225
+ fetchRemoteActor(actorUri: string, forceAvatarRefresh?: boolean, acctHint?: string): Promise<TActor | null>;
226
+ /**
227
+ * Run the app's {@link DeriveNetworkIdentity} hook and REFUSE any result the
228
+ * identity bridge could not bind.
229
+ *
230
+ * oxy-api binds a federated username to its domain, so a `federatedUsername`
231
+ * that does not end with `@${instanceDomain}` would be rejected downstream — or
232
+ * worse, mint an identity under a domain it does not name. Validating here means
233
+ * no app can produce that shape, and a hook that gets it wrong degrades to the
234
+ * actor's real protocol acct (the pre-hook behaviour) instead of losing the
235
+ * actor. The refusal is logged: it is a bug in the app's rule, not a normal
236
+ * outcome, and it must not pass silently.
237
+ */
238
+ private resolveNetworkIdentity;
239
+ /**
240
+ * Tombstone a remote actor that returned a definitive 410 Gone. Marks the stored
241
+ * actor suspended (via the store) and, when it links to an Oxy identity, asks
242
+ * oxy-api to archive it so it drops out of search.
243
+ *
244
+ * Best-effort and fail-soft: neither the store write nor the Oxy archive call is
245
+ * allowed to throw out of the caller. Idempotent.
246
+ */
247
+ tombstoneGoneActor(actorUri: string): Promise<void>;
248
+ /** Fetch the totalItems count from an ActivityPub collection URL. */
249
+ private fetchCollectionCount;
250
+ /**
251
+ * Get a cached actor or fetch if missing/stale (>24h).
252
+ *
253
+ * Never blocks on remote network I/O when a cached actor already exists: a stale
254
+ * cached actor is returned immediately and a background refresh is enqueued. Only
255
+ * a completely missing actor triggers a blocking fetch.
256
+ */
257
+ getOrFetchActor(actorUri: string): Promise<TActor | null>;
258
+ /**
259
+ * Enqueue a fire-and-forget full-actor refresh. Safe to call on a client request
260
+ * path: it returns synchronously and the fetch runs detached. Guards against
261
+ * refresh storms (in-flight dedup + a recency skip unless the profile is
262
+ * incomplete). The avatar refresh is forced only when the actor already exists.
263
+ */
264
+ refreshActorInBackground(actorUri: string, existing?: TActor): void;
265
+ /**
266
+ * Resolve a remote actor URI to its listable Oxy user id. Returns null when the
267
+ * actor cannot be resolved to an Oxy user — callers must then skip.
268
+ */
269
+ resolveActorOxyUserId(actorUri: string): Promise<string | null>;
270
+ /**
271
+ * Fetch a public key by keyId (used for HTTP signature verification).
272
+ *
273
+ * Deliberately NOT domain-policy gated on the cached branch: this answers "what
274
+ * key signs for this keyId", a question about authenticity, not about whether we
275
+ * federate with the answer. Suspending an instance is enforced where the activity
276
+ * is dispatched (`createInboundDispatcher`), so a blocked instance's signature is
277
+ * still evaluated honestly and its activity is then dropped as policy, rather
278
+ * than being reported as a forged signature. The uncached branch still refuses,
279
+ * because resolving it would mean network I/O toward a blocked host.
280
+ */
281
+ fetchPublicKey(keyId: string): Promise<{
282
+ publicKeyPem: string;
283
+ actorUri: string;
284
+ } | null>;
285
+ }
286
+ /** Build the remote-actor resolver from an app's storage + identity + transports. */
287
+ export declare function createActorResolver<TActor extends FederatedActorRecordBase>(config: ActorResolverConfig<TActor>): ActorResolver<TActor>;
@@ -0,0 +1,108 @@
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 type { AccountKind } from '@oxy.so/contracts';
23
+ import type { User } from '@oxy.so/core';
24
+ import type { UrlBuilders } from '../urls';
25
+ import type { LocalActorBuilder } from '../actorObject';
26
+ /** The resolved-user fields the actor + collection routes read. */
27
+ export interface ActorRouteUser {
28
+ _id?: string | null;
29
+ id?: string | null;
30
+ name?: {
31
+ displayName?: string | null;
32
+ } | null;
33
+ bio?: string | null;
34
+ avatar?: string | null;
35
+ createdAt?: string | null;
36
+ /** Account-graph classification — decides the actor `type`. */
37
+ kind?: AccountKind | null;
38
+ _count?: {
39
+ followers?: number;
40
+ following?: number;
41
+ } | null;
42
+ }
43
+ /** The tri-state consent read for a username with no already-resolved user object. */
44
+ export type ActorSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
45
+ /** Minimal logging sink the actor router writes to. */
46
+ export interface ActorRouterLogger {
47
+ debug(message: string, detail?: unknown): void;
48
+ warn(message: string, detail?: unknown): void;
49
+ error(message: string, detail?: unknown): void;
50
+ }
51
+ /** A page of a user's follow graph (from the authoritative Oxy graph). */
52
+ export interface FollowPage {
53
+ members: User[];
54
+ total: number;
55
+ hasMore: boolean;
56
+ }
57
+ /** Adapters + config a {@link createActorRouter} is built from. */
58
+ export interface ActorRouterConfig {
59
+ /** The app's federation domain (the human-facing `url` host + non-AP redirect target). */
60
+ domain: string;
61
+ /** Whether federation is enabled (all routes 404 when off). */
62
+ federationEnabled: boolean;
63
+ /** The AP content type (`application/activity+json`). */
64
+ apContentType: string;
65
+ /** Per-instance URL builders. */
66
+ urls: UrlBuilders;
67
+ /** True when the request's Accept header asks for ActivityPub JSON. */
68
+ wantsActivityPub(accept: string | string[] | undefined): boolean;
69
+ /** Fetch the public keyId + PEM for a username (`instance` for the server actor). */
70
+ getPublicKey(username: string): Promise<{
71
+ keyId: string;
72
+ publicKeyPem: string;
73
+ }>;
74
+ /** Resolve a username to its Oxy user (null when unknown). */
75
+ resolveUser(username: string): Promise<ActorRouteUser | null>;
76
+ /** The fediverse-sharing consent gate. */
77
+ consent: {
78
+ isSharingEnabledFromUser(user: ActorRouteUser): boolean;
79
+ getSharingStateByUsername(username: string): Promise<ActorSharingState>;
80
+ };
81
+ /** The single local-actor builder (shared with the `Update(Person)` broadcast). */
82
+ buildLocalActorObject: LocalActorBuilder;
83
+ /** The app-owned profile banner (Mention: `UserSettings.profileHeaderImage`). */
84
+ getBanner(oxyUserId: string): Promise<string | null>;
85
+ /** Inbound-delivery adapters. */
86
+ inbound: {
87
+ /** Resolve a `keyId` to its public key PEM + owning actor uri (HTTP-sig verify). */
88
+ fetchPublicKey(keyId: string): Promise<{
89
+ publicKeyPem: string;
90
+ actorUri: string;
91
+ } | null>;
92
+ /** Whether to trust `X-Forwarded-Host` when reconstructing the signed host line. */
93
+ trustForwardedHost: boolean;
94
+ /** Enqueue a verified inbound activity for async processing (false ⇒ process inline). */
95
+ enqueueInboxActivity(job: {
96
+ activity: Record<string, unknown>;
97
+ verifiedActorUri: string;
98
+ }): Promise<boolean>;
99
+ /** The inbound dispatcher (the inline-fallback + post-enqueue processor). */
100
+ processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
101
+ };
102
+ /** Fetch one page of a user's Oxy follow graph (followers OR following). */
103
+ fetchFollowPage(userId: string, direction: 'followers' | 'following', offset: number, limit: number): Promise<FollowPage>;
104
+ /** Diagnostics sink. */
105
+ logger: ActorRouterLogger;
106
+ }
107
+ /** Build the actor + inbox + follow-graph router for an app's domain. */
108
+ export declare function createActorRouter(config: ActorRouterConfig): Router;
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
3
+ * Accept(Follow)) and the `Update(Person)` actor rebroadcast.
4
+ *
5
+ * The delivery TRANSPORT (sign → SSRF-safe POST → BullMQ / durable fallback queue),
6
+ * the shared-inbox dedup fan-out, and the follow-protocol activity shapes are the
7
+ * SAME across every Oxy app, so they live here — behaviour-identical to Mention's
8
+ * former `FollowService` delivery half. Everything app-specific is injected:
9
+ *
10
+ * - private-key CUSTODY stays behind the {@link DeliveryKeys} adapter (Mention:
11
+ * oxy-api `/federation/sign` + `/federation/public-key`); the key never enters
12
+ * this package,
13
+ * - the SSRF-safe single-hop POST + the BullMQ enqueue + the durable
14
+ * fallback are the {@link DeliveryTransport}, so the delivery policy stays in
15
+ * one place (Mention's `fetchUpstreamSingleHop` + `FederationDeliveryQueue`),
16
+ * - the AP-specific `FederatedActor` / `FederatedFollow` rows stay in the app DB
17
+ * behind the {@link DeliveryActorStore} / {@link DeliveryFollowStore} adapters
18
+ * ("bring your own store" — no data move),
19
+ * - the actor cache refresh (for a follow whose target inbox is not yet known),
20
+ * the consent gate, the actor-profile resolver, the banner, and the local-actor
21
+ * builder are all injected.
22
+ *
23
+ * The CONTENT federate methods (build the Note / boost / like) STAY in the app and
24
+ * call `deliverToFollowers` / `deliverActivity` / `queueDelivery` here.
25
+ */
26
+ import type { AccountKind } from '@oxy.so/contracts';
27
+ import { type HttpSignatureSigner } from '../httpSignature';
28
+ import type { UrlBuilders } from '../urls';
29
+ import type { LocalActorBuilder } from '../actorObject';
30
+ /** Minimal logging sink the delivery service writes to. */
31
+ export interface DeliveryLogger {
32
+ debug(message: string, detail?: unknown): void;
33
+ info(message: string): void;
34
+ warn(message: string): void;
35
+ error(message: string, detail?: unknown): void;
36
+ }
37
+ /**
38
+ * Private-key custody for outbound signing. The private key NEVER enters this
39
+ * package — `getPublicKey` returns only the actor's public keyId (to name the
40
+ * signature) and `sign` delegates the RSA-SHA256 signing (Mention → oxy-api).
41
+ */
42
+ export interface DeliveryKeys {
43
+ getPublicKey(username: string): Promise<{
44
+ keyId: string;
45
+ publicKeyPem: string;
46
+ }>;
47
+ sign: HttpSignatureSigner;
48
+ }
49
+ /** A bounded, destroyable byte stream — the raw single-hop delivery response body. */
50
+ export interface DeliveryResponseStream extends AsyncIterable<Buffer | Uint8Array> {
51
+ destroy(): void;
52
+ }
53
+ /** The result of one SSRF-safe single-hop delivery POST (redirects NOT followed). */
54
+ export interface DeliverSingleHopResult {
55
+ response: DeliveryResponseStream;
56
+ status: number;
57
+ }
58
+ /** Per-request options handed to the injected single-hop delivery transport. */
59
+ export interface DeliverSingleHopInit {
60
+ method: 'POST';
61
+ headers: Record<string, string>;
62
+ body: string;
63
+ signal: AbortSignal;
64
+ headersTimeoutMs: number;
65
+ }
66
+ /**
67
+ * An SSRF-safe single-hop POST: validates + IP-pins the URL and returns the raw
68
+ * response WITHOUT following redirects. Mention adapts its `@oxy.so/core/server`-
69
+ * backed `fetchUpstreamSingleHop` into this shape.
70
+ */
71
+ export type DeliverSingleHop = (url: string, init: DeliverSingleHopInit) => Promise<DeliverSingleHopResult>;
72
+ /** A durable-delivery job body (BullMQ + the fallback queue share this shape). */
73
+ export interface DeliveryQueueJob {
74
+ activityJson: Record<string, unknown>;
75
+ targetInbox: string;
76
+ senderOxyUserId: string;
77
+ }
78
+ /**
79
+ * The durable-delivery fallback (written when BullMQ is unavailable).
80
+ *
81
+ * App-supplied, like every other store in this package: the engine never names
82
+ * a database. Mention backs it with Postgres; the method names below are the
83
+ * ones its original Mongoose collection exposed and are kept only so the
84
+ * adapter shape stays stable for consumers.
85
+ */
86
+ export interface DeliveryFallbackQueue {
87
+ /** Insert one fallback delivery row (`queueDelivery`). */
88
+ create(job: DeliveryQueueJob & {
89
+ nextAttemptAt: Date;
90
+ }): Promise<unknown>;
91
+ /** Insert many fallback delivery rows in one write (`deliverToFollowers`). */
92
+ insertMany(jobs: Array<DeliveryQueueJob & {
93
+ nextAttemptAt: Date;
94
+ }>): Promise<unknown>;
95
+ }
96
+ /** The delivery transport: BullMQ enqueue with a durable app-supplied fallback. */
97
+ export interface DeliveryTransport {
98
+ /**
99
+ * Enqueue one durable delivery. Resolves `false` when the queue is unavailable
100
+ * (Redis not configured) so the engine falls back to {@link DeliveryFallbackQueue}.
101
+ * May REJECT (the engine treats a rejection as `false` and falls back).
102
+ */
103
+ enqueueDelivery(job: DeliveryQueueJob): Promise<boolean>;
104
+ fallbackQueue: DeliveryFallbackQueue;
105
+ }
106
+ /**
107
+ * The delivery-relevant fields of a stored remote actor. `TActor` (the app's own
108
+ * `FederatedActor` shape) extends this; the engine reads only these fields and
109
+ * hands the FULL record back to `actorRefresh.refreshActorInBackground`.
110
+ */
111
+ export interface DeliveryActorFields {
112
+ _id?: unknown;
113
+ uri: string;
114
+ sharedInboxUrl?: string | null;
115
+ inboxUrl?: string | null;
116
+ manuallyApprovesFollowers?: boolean;
117
+ }
118
+ /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
119
+ export interface DeliveryActorStore<TActor extends DeliveryActorFields> {
120
+ /** One cached actor by uri (`resolveActorInbox` / `sendFollow` / `sendUndoFollow` / `sendAccept`). */
121
+ findActorByUri(uri: string): Promise<TActor | null>;
122
+ /** Inbox fields for many actor uris (`deliverToFollowers`, step 2). */
123
+ findActorInboxesByUris(uris: string[]): Promise<Array<Pick<DeliveryActorFields, 'sharedInboxUrl' | 'inboxUrl'>>>;
124
+ }
125
+ /** Bring-your-own-store: the AP follow records stay in the app DB behind this adapter. */
126
+ export interface DeliveryFollowStore {
127
+ /** Accepted inbound followers' remote actor uris (`deliverToFollowers`, step 1). */
128
+ listAcceptedInboundFollowerActorUris(localOxyUserId: string): Promise<string[]>;
129
+ /** Upsert an outbound pending follow with its activity id (`sendFollow`). */
130
+ upsertOutboundPending(localOxyUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
131
+ /** The outbound follow row for `(localOxyUserId, remoteActorUri)` (`sendUndoFollow`). */
132
+ findOutbound(localOxyUserId: string, remoteActorUri: string): Promise<{
133
+ _id: unknown;
134
+ activityId?: string;
135
+ } | null>;
136
+ /** Delete a follow row by id (`sendUndoFollow`). */
137
+ deleteById(id: unknown): Promise<void>;
138
+ }
139
+ /** The actor-cache refresh the follow path uses when a target inbox is not yet known. */
140
+ export interface DeliveryActorRefresh<TActor extends DeliveryActorFields> {
141
+ /** Fire-and-forget full-actor refresh (keeps a followee's inbox/profile current). */
142
+ refreshActorInBackground(actorUri: string, existing?: TActor): void;
143
+ /** Blocking actor fetch to resolve an inbox when none is cached (`queueFollowOnceActorKnown`). */
144
+ fetchRemoteActor(actorUri: string): Promise<TActor | null>;
145
+ }
146
+ /** The consent gate — only `federateActorUpdate` gates outbound delivery on it. */
147
+ export interface DeliveryConsent {
148
+ isSharingEnabled(oxyUserId: string): Promise<boolean>;
149
+ }
150
+ /** The Oxy profile fields the actor `Update` rebroadcast reads. */
151
+ export interface DeliveryActorProfile {
152
+ name?: {
153
+ displayName?: string | null;
154
+ } | null;
155
+ bio?: string | null;
156
+ avatar?: string | null;
157
+ createdAt?: string | null;
158
+ /**
159
+ * Account-graph classification — decides the actor `type`. It MUST travel with
160
+ * the rebroadcast: an `Update` carrying a different `type` from the one the GET
161
+ * route serves is exactly the drift the shared builder exists to prevent, and
162
+ * on a type change it is also the migration vehicle (a follower's instance
163
+ * adopts the new type from this push rather than waiting out its own actor
164
+ * staleness window).
165
+ */
166
+ kind?: AccountKind | null;
167
+ }
168
+ /** Resolve a local username to its Oxy profile (for the `Update(Person)` rebroadcast). */
169
+ export interface DeliveryIdentity {
170
+ resolveUserByUsername(username: string): Promise<DeliveryActorProfile | null>;
171
+ }
172
+ /** The app-owned profile banner (Mention: `UserSettings.profileHeaderImage`). */
173
+ export interface DeliveryProfile {
174
+ getBanner(oxyUserId: string): Promise<string | null>;
175
+ }
176
+ /** The result shape of the injected SSRF pre-check for a durable inbox enqueue. */
177
+ export interface SafeUrlVerdict {
178
+ ok: boolean;
179
+ reason?: string;
180
+ }
181
+ /** Adapters + config a {@link DeliveryService} is built from. */
182
+ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
183
+ /** Whether federation is enabled (gates `sendFollow`/`sendUndoFollow`/`federateActorUpdate`). */
184
+ federationEnabled: boolean;
185
+ /** User-Agent presented to remote inboxes. */
186
+ userAgent: string;
187
+ /** The AP content type (`application/activity+json`) used for delivery headers. */
188
+ apContentType: string;
189
+ /** Private-key custody + public keyId lookup. */
190
+ keys: DeliveryKeys;
191
+ /** Per-instance URL builders (actor URL for the follow/update activities). */
192
+ urls: UrlBuilders;
193
+ /** SSRF-safe single-hop delivery POST (does NOT follow redirects). */
194
+ deliverSingleHop: DeliverSingleHop;
195
+ /** SSRF pre-check for a durable inbox enqueue (never queue a delivery to an unsafe URL). */
196
+ assertSafeInboxUrl(url: string): Promise<SafeUrlVerdict>;
197
+ /** BullMQ enqueue + the durable fallback queue. */
198
+ transport: DeliveryTransport;
199
+ /** The AP actor cache store. */
200
+ store: DeliveryActorStore<TActor>;
201
+ /** The AP follow-record store. */
202
+ follows: DeliveryFollowStore;
203
+ /** Actor-cache refresh for the follow path. */
204
+ actorRefresh: DeliveryActorRefresh<TActor>;
205
+ /** The fediverse-sharing consent gate (used by `federateActorUpdate` only). */
206
+ consent: DeliveryConsent;
207
+ /** Resolve a local username to its Oxy profile (`federateActorUpdate`). */
208
+ identity: DeliveryIdentity;
209
+ /** App-owned profile banner (`federateActorUpdate`). */
210
+ profile: DeliveryProfile;
211
+ /** The single local-actor builder (shared with the actor GET route). */
212
+ buildLocalActorObject: LocalActorBuilder;
213
+ /** Diagnostics sink. */
214
+ logger: DeliveryLogger;
215
+ /**
216
+ * The app's per-instance domain policy (`DomainPolicy.isBlockedDomain`) — the
217
+ * same predicate inbound dispatch and actor resolution use. Outbound delivery
218
+ * must refuse blocked origins symmetrically: a domain blocked inbound must not
219
+ * keep receiving Follow/Undo/Accept or follower fan-out via a cached inbox.
220
+ */
221
+ isBlockedDomain(host: string): boolean;
222
+ }
223
+ /** The outbound delivery + follow-lifecycle service. */
224
+ export interface DeliveryService {
225
+ /** Deliver an activity to one remote inbox, signed with the sender's key. */
226
+ deliverActivity(activity: Record<string, unknown>, targetInbox: string, senderOxyUserId: string, senderUsername: string): Promise<boolean>;
227
+ /** Queue one activity for durable delivery (BullMQ, fallback queue). */
228
+ queueDelivery(activity: Record<string, unknown>, targetInbox: string, senderOxyUserId: string): Promise<void>;
229
+ /** Resolve a remote actor's delivery inbox (shared preferred) from the store. */
230
+ resolveActorInbox(actorUri: string | undefined): Promise<string | undefined>;
231
+ /** Deliver to all accepted inbound followers plus `options.extraInboxes` (deduped by shared inbox). */
232
+ deliverToFollowers(activity: Record<string, unknown>, senderOxyUserId: string, senderUsername: string, options?: {
233
+ extraInboxes?: string[];
234
+ }): Promise<void>;
235
+ /** Send a Follow activity to a remote actor (records the outbound follow, delivers/queues). */
236
+ sendFollow(localOxyUserId: string, localUsername: string, remoteActorUri: string): Promise<{
237
+ success: boolean;
238
+ pending: boolean;
239
+ }>;
240
+ /** Send an Undo(Follow) to a remote actor (removes the local follow first). */
241
+ sendUndoFollow(localOxyUserId: string, localUsername: string, remoteActorUri: string): Promise<boolean>;
242
+ /** Send an Accept(Follow) back to a remote actor. */
243
+ sendAccept(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
244
+ /** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
245
+ federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
246
+ }
247
+ /** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
248
+ export declare function createDeliveryService<TActor extends DeliveryActorFields>(config: DeliveryServiceConfig<TActor>): DeliveryService;