@oxy.so/federation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/actorObject.js +216 -0
- package/dist/cjs/apContext.js +48 -0
- package/dist/cjs/apUri.js +132 -0
- package/dist/cjs/httpSignature.js +187 -0
- package/dist/cjs/index.js +99 -0
- package/dist/cjs/networkIdentity.js +487 -0
- package/dist/cjs/node/actorResolver.js +625 -0
- package/dist/cjs/node/actorRouter.js +307 -0
- package/dist/cjs/node/delivery.js +415 -0
- package/dist/cjs/node/identityBridge.js +133 -0
- package/dist/cjs/node/inboundDispatch.js +268 -0
- package/dist/cjs/node/index.js +63 -0
- package/dist/cjs/node/signedFetch.js +122 -0
- package/dist/cjs/node/webfingerRouter.js +166 -0
- package/dist/cjs/urls.js +55 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/actorObject.js +210 -0
- package/dist/esm/apContext.js +45 -0
- package/dist/esm/apUri.js +126 -0
- package/dist/esm/httpSignature.js +179 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/networkIdentity.js +472 -0
- package/dist/esm/node/actorResolver.js +620 -0
- package/dist/esm/node/actorRouter.js +304 -0
- package/dist/esm/node/delivery.js +412 -0
- package/dist/esm/node/identityBridge.js +130 -0
- package/dist/esm/node/inboundDispatch.js +263 -0
- package/dist/esm/node/index.js +51 -0
- package/dist/esm/node/signedFetch.js +119 -0
- package/dist/esm/node/webfingerRouter.js +163 -0
- package/dist/esm/urls.js +50 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/actorObject.d.ts +182 -0
- package/dist/types/apContext.d.ts +35 -0
- package/dist/types/apUri.d.ts +107 -0
- package/dist/types/httpSignature.d.ts +113 -0
- package/dist/types/index.d.ts +336 -0
- package/dist/types/networkIdentity.d.ts +509 -0
- package/dist/types/node/actorResolver.d.ts +287 -0
- package/dist/types/node/actorRouter.d.ts +108 -0
- package/dist/types/node/delivery.d.ts +248 -0
- package/dist/types/node/identityBridge.d.ts +84 -0
- package/dist/types/node/inboundDispatch.d.ts +156 -0
- package/dist/types/node/index.d.ts +51 -0
- package/dist/types/node/signedFetch.d.ts +74 -0
- package/dist/types/node/webfingerRouter.d.ts +62 -0
- package/dist/types/urls.d.ts +55 -0
- package/package.json +119 -0
- package/src/__tests__/actorObject.test.ts +258 -0
- package/src/__tests__/actorResolver.test.ts +252 -0
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
- package/src/__tests__/apUri.test.ts +53 -0
- package/src/__tests__/delivery.test.ts +432 -0
- package/src/__tests__/federationHost.test.ts +281 -0
- package/src/__tests__/httpSignature.test.ts +343 -0
- package/src/__tests__/inboundDispatch.test.ts +381 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/networkIdentity.test.ts +525 -0
- package/src/__tests__/routers.test.ts +460 -0
- package/src/__tests__/urls.test.ts +26 -0
- package/src/actorObject.ts +313 -0
- package/src/apContext.ts +45 -0
- package/src/apUri.ts +161 -0
- package/src/httpSignature.ts +282 -0
- package/src/index.ts +419 -0
- package/src/networkIdentity.ts +731 -0
- package/src/node/actorResolver.ts +839 -0
- package/src/node/actorRouter.ts +438 -0
- package/src/node/delivery.ts +729 -0
- package/src/node/identityBridge.ts +230 -0
- package/src/node/inboundDispatch.ts +420 -0
- package/src/node/index.ts +136 -0
- package/src/node/signedFetch.ts +177 -0
- package/src/node/webfingerRouter.ts +226 -0
- package/src/urls.ts +71 -0
|
@@ -0,0 +1,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;
|