@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,282 @@
1
+ /**
2
+ * HTTP Signatures (draft-cavage-http-signatures-12) — the PURE sign/verify
3
+ * crypto that every Oxy app's ActivityPub federation shares.
4
+ *
5
+ * This is the highest-risk surface in the federation engine: the exact bytes of
6
+ * the signing string, the covered-header list and its order, the signature
7
+ * parameters, and the `X-Forwarded-Host` host reconstruction are what remote
8
+ * servers (Mastodon et al.) verify against. A one-character drift silently kills
9
+ * ALL federation, so this module is a byte-for-byte extraction of Mention's
10
+ * proven implementation — with the ONLY behavioural knobs made explicit:
11
+ *
12
+ * - **private-key custody is injected** ({@link HttpSignatureSigner}). The
13
+ * private key NEVER enters this package; the app supplies a `sign(keyId,
14
+ * signingString)` that (for Mention) calls oxy-api `POST /federation/sign`.
15
+ * - **`X-Forwarded-Host` trust is opt-in** ({@link VerifyHttpSignatureOptions.trustForwardedHost}).
16
+ * Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
17
+ * passes `true`; a directly-exposed origin leaves it `false`.
18
+ *
19
+ * Lives in the isomorphic `.` entry (no Express / Mongoose): it depends only on
20
+ * the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
21
+ * React-Native bundles — RN consumers import only the connector TYPES, which are
22
+ * erased at compile time.
23
+ */
24
+
25
+ import crypto from 'node:crypto';
26
+
27
+ /** The signature algorithm parameter emitted in (and expected on) the `Signature` header. */
28
+ export const HTTP_SIGNATURE_ALGORITHM = 'rsa-sha256';
29
+
30
+ /**
31
+ * The default content-type folded into the signing string for body-bearing
32
+ * requests. ActivityPub delivery signs `content-type` (some servers — e.g.
33
+ * Threads — require it), and the AP content type is always
34
+ * `application/activity+json`.
35
+ */
36
+ export const DEFAULT_SIGNED_CONTENT_TYPE = 'application/activity+json';
37
+
38
+ /**
39
+ * Signs an already-composed signing string with the private key backing `keyId`
40
+ * and returns the base64 RSA-SHA256 signature. The private key custody lives
41
+ * behind this function — for Mention it delegates to oxy-api's
42
+ * `POST /federation/sign` so the key never leaves Oxy.
43
+ */
44
+ export type HttpSignatureSigner = (keyId: string, signingString: string) => Promise<string>;
45
+
46
+ /** Options controlling the signing-string composition (all optional). */
47
+ export interface SignRequestOptions {
48
+ /**
49
+ * The content-type value included in the signing string (and covered by the
50
+ * signature) for body-bearing requests. Defaults to
51
+ * {@link DEFAULT_SIGNED_CONTENT_TYPE}. The Content-Type request HEADER itself is
52
+ * set by the deliverer's fetch, not returned here.
53
+ */
54
+ contentType?: string;
55
+ }
56
+
57
+ /**
58
+ * Build the HTTP Signature header per draft-cavage-http-signatures-12 and sign it
59
+ * via the injected {@link HttpSignatureSigner} (the private key never enters this
60
+ * package).
61
+ *
62
+ * The spec-correct signing string is composed locally: `(request-target)`, host,
63
+ * date, and — for body-bearing requests — digest and content-type. The composed
64
+ * string is handed to `sign`, and the resulting signature is assembled into the
65
+ * `Signature:` header.
66
+ *
67
+ * Returns the headers to attach to the outbound request (Host, Date, optional
68
+ * Digest, and Signature). Content-Type is set by the deliverer's fetch.
69
+ */
70
+ export async function signRequest(
71
+ sign: HttpSignatureSigner,
72
+ keyId: string,
73
+ method: string,
74
+ url: string,
75
+ body?: string,
76
+ options: SignRequestOptions = {},
77
+ ): Promise<Record<string, string>> {
78
+ const contentType = options.contentType ?? DEFAULT_SIGNED_CONTENT_TYPE;
79
+ const parsedUrl = new URL(url);
80
+ const date = new Date().toUTCString();
81
+ const headers: Record<string, string> = {
82
+ Host: parsedUrl.host,
83
+ Date: date,
84
+ };
85
+
86
+ const signedHeaderNames = ['(request-target)', 'host', 'date'];
87
+ const signingParts = [
88
+ `(request-target): ${method.toLowerCase()} ${parsedUrl.pathname}${parsedUrl.search}`,
89
+ `host: ${parsedUrl.host}`,
90
+ `date: ${date}`,
91
+ ];
92
+
93
+ if (body) {
94
+ const digest = crypto.createHash('sha256').update(body).digest('base64');
95
+ headers.Digest = `SHA-256=${digest}`;
96
+ signedHeaderNames.push('digest');
97
+ signingParts.push(`digest: SHA-256=${digest}`);
98
+ // Include content-type in signature (required by some servers like Threads)
99
+ signedHeaderNames.push('content-type');
100
+ signingParts.push(`content-type: ${contentType}`);
101
+ }
102
+
103
+ const signingString = signingParts.join('\n');
104
+ const signature = await sign(keyId, signingString);
105
+
106
+ headers.Signature = [
107
+ `keyId="${keyId}"`,
108
+ `algorithm="${HTTP_SIGNATURE_ALGORITHM}"`,
109
+ `headers="${signedHeaderNames.join(' ')}"`,
110
+ `signature="${signature}"`,
111
+ ].join(',');
112
+
113
+ return headers;
114
+ }
115
+
116
+ /**
117
+ * Parse the Signature header from an incoming request.
118
+ */
119
+ function parseSignatureHeader(signatureHeader: string): {
120
+ keyId: string;
121
+ algorithm: string;
122
+ headers: string[];
123
+ signature: string;
124
+ } | null {
125
+ const params: Record<string, string> = {};
126
+ const regex = /(\w+)="([^"]*)"/g;
127
+ let match = regex.exec(signatureHeader);
128
+ while (match !== null) {
129
+ params[match[1]] = match[2];
130
+ match = regex.exec(signatureHeader);
131
+ }
132
+
133
+ if (!params.keyId || !params.signature) return null;
134
+
135
+ return {
136
+ keyId: params.keyId,
137
+ algorithm: params.algorithm || HTTP_SIGNATURE_ALGORITHM,
138
+ headers: (params.headers || 'date').split(' '),
139
+ signature: params.signature,
140
+ };
141
+ }
142
+
143
+ /** An inbound request reduced to what signature verification needs. */
144
+ export interface VerifyHttpRequest {
145
+ method: string;
146
+ path: string;
147
+ headers: Record<string, string | string[] | undefined>;
148
+ body?: unknown;
149
+ }
150
+
151
+ /** The verdict of {@link verifyHttpSignature}. */
152
+ export interface VerifyHttpResult {
153
+ verified: boolean;
154
+ actorUri?: string;
155
+ reason?: string;
156
+ }
157
+
158
+ /**
159
+ * Resolve a `keyId` to its public key PEM and the actor URI that owns it, or
160
+ * `null` when the key cannot be fetched (a failed fetch fails verification).
161
+ */
162
+ export type FetchPublicKey = (
163
+ keyId: string,
164
+ ) => Promise<{ publicKeyPem: string; actorUri: string } | null>;
165
+
166
+ /** Options controlling inbound signature verification. */
167
+ export interface VerifyHttpSignatureOptions {
168
+ /**
169
+ * When `true`, reconstruct the signed `host` line from `X-Forwarded-Host`
170
+ * (first comma token) instead of `Host` when the header is present.
171
+ *
172
+ * Load-bearing for an edge-proxied apex: when a CDN/edge rewrites the origin
173
+ * `Host` (e.g. `mention.earth` → `api.mention.earth`) and forwards the ORIGINAL
174
+ * signed host in `X-Forwarded-Host`, the verifier must rebuild the `host`
175
+ * signing line from it or the reconstructed string never matches what the
176
+ * sender signed. A proxy chain may append a comma-separated list whose FIRST
177
+ * token is the client-facing host. This grants a forger nothing: the signature
178
+ * cryptographically binds whatever host value the sender signed, so a bogus
179
+ * `X-Forwarded-Host` simply fails verification. Falls back to `host` when the
180
+ * header is absent (direct delivery). Defaults to `false` (trust only `Host`).
181
+ */
182
+ trustForwardedHost?: boolean;
183
+ /**
184
+ * Optional sink for non-fatal diagnostics (key-fetch failure, verify
185
+ * exception). No-op when omitted. Kept out of the return value so verdicts
186
+ * stay data-only.
187
+ */
188
+ onDebug?: (message: string, detail?: unknown) => void;
189
+ }
190
+
191
+ /**
192
+ * Verify the HTTP signature on an incoming request.
193
+ * Returns the actor URI (key owner) if valid, null otherwise.
194
+ */
195
+ export async function verifyHttpSignature(
196
+ req: VerifyHttpRequest,
197
+ fetchPublicKey: FetchPublicKey,
198
+ options: VerifyHttpSignatureOptions = {},
199
+ ): Promise<VerifyHttpResult> {
200
+ const signatureHeader = req.headers.signature as string | undefined;
201
+ if (!signatureHeader) return { verified: false, reason: 'missing-signature' };
202
+
203
+ const parsed = parseSignatureHeader(signatureHeader);
204
+ if (!parsed) return { verified: false, reason: 'invalid-signature-header' };
205
+
206
+ const keyData = await fetchPublicKey(parsed.keyId);
207
+ if (!keyData) {
208
+ options.onDebug?.(`Failed to fetch public key for keyId: ${parsed.keyId}`);
209
+ return { verified: false, reason: 'key-fetch-failed' };
210
+ }
211
+
212
+ const lowerHeaders = Object.fromEntries(
213
+ Object.entries(req.headers).map(([k, v]) => [k.toLowerCase(), v]),
214
+ );
215
+
216
+ // Enforce Date skew (+/- 10 minutes) if present
217
+ const dateHeader = lowerHeaders.date;
218
+ if (dateHeader) {
219
+ const dateVal = Array.isArray(dateHeader) ? dateHeader[0] : dateHeader;
220
+ const parsedDate = Date.parse(dateVal || '');
221
+ if (!Number.isNaN(parsedDate)) {
222
+ const skew = Math.abs(Date.now() - parsedDate);
223
+ if (skew > 10 * 60 * 1000) {
224
+ return { verified: false, reason: 'date-skew' };
225
+ }
226
+ }
227
+ }
228
+
229
+ // If Digest header is required in signature but missing/invalid, fail early
230
+ if (parsed.headers.includes('digest')) {
231
+ const digestHeader = lowerHeaders.digest;
232
+ const bodyString =
233
+ typeof req.body === 'string' ? req.body : req.body ? JSON.stringify(req.body) : '';
234
+ if (!digestHeader) {
235
+ return { verified: false, reason: 'missing-digest' };
236
+ }
237
+ const expectedDigest = `SHA-256=${crypto.createHash('sha256').update(bodyString).digest('base64')}`;
238
+ const digestVal = Array.isArray(digestHeader) ? digestHeader[0] : digestHeader;
239
+ if (digestVal !== expectedDigest) {
240
+ return { verified: false, reason: 'digest-mismatch' };
241
+ }
242
+ }
243
+
244
+ const signingParts = parsed.headers.map((header) => {
245
+ const name = header.toLowerCase();
246
+ if (name === '(request-target)') {
247
+ return `(request-target): ${req.method.toLowerCase()} ${req.path}`;
248
+ }
249
+ // Reconstruct the `host` line from `x-forwarded-host` when the caller trusts
250
+ // it (an edge that rewrites the origin Host forwards the ORIGINAL signed host
251
+ // here; a proxy chain's FIRST comma token is the client-facing host). See
252
+ // VerifyHttpSignatureOptions.trustForwardedHost. Falls back to `host` when the
253
+ // header is absent (direct delivery), preserving direct-delivery behavior.
254
+ if (name === 'host' && options.trustForwardedHost) {
255
+ const forwarded = lowerHeaders['x-forwarded-host'];
256
+ const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
257
+ const firstToken = forwardedValue?.split(',')[0]?.trim();
258
+ if (firstToken) {
259
+ return `host: ${firstToken}`;
260
+ }
261
+ }
262
+ const value = lowerHeaders[name];
263
+ return `${name}: ${Array.isArray(value) ? value[0] : value}`;
264
+ });
265
+
266
+ const signingString = signingParts.join('\n');
267
+ const verifier = crypto.createVerify('sha256');
268
+ verifier.update(signingString);
269
+ verifier.end();
270
+
271
+ try {
272
+ const isValid = verifier.verify(keyData.publicKeyPem, parsed.signature, 'base64');
273
+ return {
274
+ verified: isValid,
275
+ actorUri: isValid ? keyData.actorUri : undefined,
276
+ reason: isValid ? undefined : 'verify-failed',
277
+ };
278
+ } catch (err) {
279
+ options.onDebug?.('HTTP signature verification failed:', err);
280
+ return { verified: false, reason: err instanceof Error ? err.message : 'verify-exception' };
281
+ }
282
+ }
package/src/index.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * @oxy.so/federation — the app-agnostic federation substrate (isomorphic `.` entry).
3
+ *
4
+ * The pluggable network-connector CONTRACT and the normalized, cross-network
5
+ * DTOs every connector produces. An app's content/MTN core never knows about
6
+ * Mastodon (ActivityPub) or Bluesky (atproto); it only ever talks to a
7
+ * {@link NetworkConnector}. This module is that seam: the normalized DTOs every
8
+ * connector produces, the local-event union connectors deliver outbound, and
9
+ * the connector interface itself.
10
+ *
11
+ * IMPORTANT: this entry is intentionally free of Mongoose / Express / React
12
+ * Native so it can be imported from any Oxy app backend (and, in later phases,
13
+ * share the pure HTTP-signature + actor-object surface with browser/isomorphic
14
+ * callers). The runnable Express/Node engine — signed fetch, delivery transport,
15
+ * webfinger/actor/inbox routers, remote-actor resolution — lives under the
16
+ * separate `./node` subpath so it never enters isomorphic bundles.
17
+ *
18
+ * The one piece of app-specific data that flows through the outbound seam — a
19
+ * local post's canonical content — is a TYPE PARAMETER (`TContent`), supplied by
20
+ * the consuming app (Mention passes its `PostContent`). The engine holds no
21
+ * knowledge of any app's post shape.
22
+ */
23
+
24
+ /**
25
+ * HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
26
+ * ActivityPub federation shares. Private-key custody is injected; the key never
27
+ * enters this package.
28
+ */
29
+ export {
30
+ signRequest,
31
+ verifyHttpSignature,
32
+ HTTP_SIGNATURE_ALGORITHM,
33
+ DEFAULT_SIGNED_CONTENT_TYPE,
34
+ type HttpSignatureSigner,
35
+ type SignRequestOptions,
36
+ type VerifyHttpRequest,
37
+ type VerifyHttpResult,
38
+ type FetchPublicKey,
39
+ type VerifyHttpSignatureOptions,
40
+ } from './httpSignature';
41
+
42
+ /**
43
+ * Domain-parameterized ActivityPub URL builders — each app instantiates them once
44
+ * with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
45
+ */
46
+ export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, type UrlBuilders } from './urls';
47
+
48
+ /**
49
+ * Network identity: the MECHANISM for re-labelling an account republished by a
50
+ * bridge onto the network it actually came from, plus the network vocabulary and
51
+ * the bidirectional upstream-profile-URL rule.
52
+ *
53
+ * The mechanism is here; the ENTRIES are not, and must not be. Which operators
54
+ * may be trusted to re-attribute somebody's account is a moderation judgement an
55
+ * app commits and answers for — `createBridgeRelabeller` takes them as a
56
+ * parameter so no app inherits another's.
57
+ */
58
+ export {
59
+ FEDERATION_NETWORKS,
60
+ BSKY_NETWORK_DOMAIN,
61
+ blueskyUsernameFromHandle,
62
+ createBridgeRelabeller,
63
+ stripBridgeBoilerplate,
64
+ upstreamProfileUrl,
65
+ parseUpstreamProfileUrl,
66
+ federatedUsernameFromUpstreamUrl,
67
+ upstreamHandleFromProfileField,
68
+ upstreamHandleFromAlsoKnownAs,
69
+ upstreamHandleFromAutomatedActor,
70
+ upstreamHandleFromPreferredUsername,
71
+ upstreamHandleFromProxyOf,
72
+ readProxyDeclarations,
73
+ type FederationNetwork,
74
+ type FederationBridgeEntry,
75
+ type BridgeRelabeller,
76
+ type BridgeConsentModel,
77
+ type BridgeDerivation,
78
+ type BridgedActorField,
79
+ type DeriveNetworkIdentity,
80
+ type NetworkIdentity,
81
+ type NetworkIdentityCandidate,
82
+ type ProxyDeclaration,
83
+ } from './networkIdentity';
84
+
85
+ /**
86
+ * The shared JSON-LD `@context` (load-bearing term declarations) and the
87
+ * ActivityPub URI helpers (actor-uri extraction + the per-instance domain policy:
88
+ * blocked-domain check + local-post-id extraction).
89
+ *
90
+ * `canonicalFederationHost` / `isSameFederationHost` are exported because the
91
+ * domain policy is not the only thing that has to decide whether two spellings
92
+ * are the same host: a moderation blocklist, a transparency page and a content
93
+ * purge all ask the same question about the same hosts, and any of them keeping
94
+ * its own copy of the rule is a second opinion waiting to diverge from the one
95
+ * the engine enforces. They are the very functions {@link createDomainPolicy} is
96
+ * built from — not a parallel implementation that agrees today.
97
+ */
98
+ export { AP_CONTEXT } from './apContext';
99
+ export {
100
+ canonicalFederationHost,
101
+ isSameFederationHost,
102
+ extractActorUriFromActivityId,
103
+ createDomainPolicy,
104
+ type DomainPolicy,
105
+ type DomainPolicyConfig,
106
+ } from './apUri';
107
+
108
+ /**
109
+ * The single builder of a LOCAL user's ActivityPub actor document —
110
+ * byte-identical across apps, with media resolution injected. The actor `type`
111
+ * follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
112
+ */
113
+ export {
114
+ createLocalActorBuilder,
115
+ localActorTypeForAccountKind,
116
+ isApActorType,
117
+ AP_ACTOR_TYPES,
118
+ LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND,
119
+ type ApActorType,
120
+ type LocalActorType,
121
+ type LocalActorBuilder,
122
+ type LocalActorBuilderConfig,
123
+ type BuildLocalActorParams,
124
+ type ActorMediaResolver,
125
+ } from './actorObject';
126
+
127
+ /** Supported external networks. */
128
+ export type NetworkId = 'activitypub' | 'atproto';
129
+
130
+ /**
131
+ * A remote actor normalized into a network-neutral shape. Built by a connector
132
+ * from its protocol's profile representation, and consumed by the identity
133
+ * bridge ({@link NetworkConnector.mapIdentity}) to resolve/mint the federated
134
+ * Oxy user the actor maps to.
135
+ */
136
+ export interface NormalizedExternalActor {
137
+ network: NetworkId;
138
+ /** Stable protocol id: an ActivityPub actor URI, or an atproto DID. */
139
+ externalId: string;
140
+ /** Fediverse-style handle (`user@domain` for AP; the atproto handle/DID otherwise). */
141
+ handle: string;
142
+ /**
143
+ * The canonical `local@domain` username this actor is stored under in Oxy — the
144
+ * exact value passed to `PUT /users/resolve`. Each connector derives it for its
145
+ * own protocol so the shared identity bridge never has to guess: AP uses the
146
+ * acct (`user@domain`); atproto synthesizes `<username>@<instance-domain>`, where
147
+ * a default Bluesky handle drops the redundant `.bsky.social` suffix
148
+ * (`skylee1.bsky.social` → `skylee1@bsky.social`) and a custom domain keeps its
149
+ * whole handle (`mayor.nyc.gov` → `mayor.nyc.gov@bsky.social`). It MUST equal
150
+ * `instanceDomain` after the `@` so oxy-api's username↔domain binding holds.
151
+ */
152
+ federatedUsername: string;
153
+ /**
154
+ * The instance/origin domain this actor's identity belongs to — the `domain`
155
+ * passed to `PUT /users/resolve` and stamped on imported `Post.instanceDomain`.
156
+ * AP: the actor host (e.g. `mastodon.social`); atproto: the handle's parent
157
+ * domain (e.g. `bsky.social`), since a DID carries no host.
158
+ */
159
+ instanceDomain: string;
160
+ displayName?: string;
161
+ avatarUrl?: string;
162
+ bannerUrl?: string;
163
+ bio?: string;
164
+ followersCount?: number;
165
+ followingCount?: number;
166
+ postsCount?: number;
167
+ /** The Oxy user this actor resolves to, once known. */
168
+ oxyUserId?: string;
169
+ }
170
+
171
+ /** A single media item on a normalized external post (mirrors the Post media shape). */
172
+ export interface NormalizedExternalMedia {
173
+ id: string;
174
+ type: 'image' | 'video';
175
+ remoteUrl?: string;
176
+ alt?: string;
177
+ width?: number;
178
+ height?: number;
179
+ durationSec?: number;
180
+ orientation?: 'portrait' | 'landscape' | 'square';
181
+ aspectRatio?: number;
182
+ }
183
+
184
+ /**
185
+ * A remote post normalized into a network-neutral shape. Mirrors the
186
+ * `Post.federation` provenance block plus the author and media a connector
187
+ * resolves while importing it.
188
+ */
189
+ export interface NormalizedExternalPost {
190
+ network: NetworkId;
191
+ /** Globally-unique provenance id (AP activity/object id, or atproto at:// URI). */
192
+ activityId: string;
193
+ /** Authoring actor's protocol id (AP actor URI / atproto DID). */
194
+ actorUri: string;
195
+ url?: string;
196
+ inReplyTo?: string;
197
+ sensitive?: boolean;
198
+ spoilerText?: string;
199
+ /** Resolved Oxy author, when the actor already maps to an Oxy user. */
200
+ authorOxyUserId?: string;
201
+ text: string;
202
+ media?: NormalizedExternalMedia[];
203
+ hashtags?: string[];
204
+ /**
205
+ * Resolved @mention Oxy user ids — the stored `mentions` allowlist, keyed by the
206
+ * `[mention:<id>]` placeholders the connector rewrote into {@link text}.
207
+ */
208
+ mentions?: string[];
209
+ /**
210
+ * The quoted post's external URI (an atproto `at://` URI / an AP quote uri) when
211
+ * this post quotes another. Resolved to a local `quoteOf` Post id at import time
212
+ * by matching an imported post's `federation.activityId`; left unresolved (no
213
+ * quote link) when the quoted post is not imported locally.
214
+ */
215
+ quotedUri?: string;
216
+ language?: string;
217
+ languages?: string[];
218
+ createdAt?: Date;
219
+ }
220
+
221
+ /** Options for paging a connector's post fetch. */
222
+ export interface FetchPostsOptions {
223
+ limit?: number;
224
+ cursor?: string;
225
+ }
226
+
227
+ /** Result of a connector post fetch (opaque per-connector cursor). */
228
+ export interface FetchPostsResult {
229
+ posts: NormalizedExternalPost[];
230
+ cursor?: string;
231
+ }
232
+
233
+ /**
234
+ * Local-post shape a `post.create` event carries to outbound delivery.
235
+ *
236
+ * `content` is the consuming app's CANONICAL post-content type (`TContent`), not
237
+ * a trimmed-down copy: a connector needs the post's localized variants and
238
+ * primary language to declare the post's language on the wire (ActivityPub
239
+ * `contentMap`, atproto `langs`), and a narrowed structural type here would
240
+ * silently DROP them at the seam. The federation package never inspects
241
+ * `content`; it flows through untouched to the app's own connector.
242
+ */
243
+ export interface LocalPostEventPayload<TContent = unknown> {
244
+ _id: unknown;
245
+ content: TContent;
246
+ hashtags?: string[];
247
+ mentions?: string[];
248
+ /** The classifier's resolved primary language — the fallback when the author declared no primary tag. */
249
+ language?: string;
250
+ visibility: string;
251
+ createdAt: string;
252
+ /**
253
+ * The boosted original's local Post `_id` when this post is a boost
254
+ * (`type: 'boost'`). A boost carries an intentionally EMPTY body and MUST NOT
255
+ * federate as a `Create(Note)` — the connector re-routes it to an `Announce`.
256
+ * Preserving it through the seam is what lets `POST /posts` `boost_of` avoid
257
+ * emitting a blank Create.
258
+ */
259
+ boostOf?: string | null;
260
+ /**
261
+ * The parent's local Post `_id` when this post is a REPLY. The connector emits
262
+ * the Note with `inReplyTo` (the parent's canonical AP object id) + a
263
+ * parent-author `Mention`, and unions the parent author's inbox into delivery so
264
+ * a reply to a remote post threads and notifies its author. Preserving it through
265
+ * the seam is what lets the `/feed/reply` path federate replies. Absent for a
266
+ * top-level post.
267
+ */
268
+ parentPostId?: string | null;
269
+ }
270
+
271
+ /**
272
+ * The minimal boost shape a `post.boost` / `post.unboost` event carries to
273
+ * outbound delivery. A boost has no body of its own; the connector federates it
274
+ * as an `Announce` (or `Undo(Announce)`) of the original post's canonical AP id,
275
+ * resolved from `boostOf`. `createdAt` stamps the activity's `published`.
276
+ */
277
+ export interface LocalBoostEventPayload {
278
+ _id: unknown;
279
+ boostOf: string;
280
+ createdAt: string | Date;
281
+ }
282
+
283
+ /**
284
+ * The minimal shape a `post.delete` event carries. A local post's canonical AP
285
+ * object id is minted deterministically from the deleter's username + this `_id`
286
+ * (`https://<domain>/ap/users/<username>/posts/<_id>`), so the connector needs
287
+ * nothing more to emit a `Delete(Tombstone)`. The post row is already gone by the
288
+ * time this fires — the id is captured BEFORE deletion.
289
+ */
290
+ export interface LocalDeleteEventPayload {
291
+ _id: unknown;
292
+ }
293
+
294
+ /**
295
+ * The shape a `post.like` / `post.unlike` event carries to outbound delivery.
296
+ * A federated-post like federates as a `Like` (or `Undo(Like)`) whose `object` is
297
+ * the liked original's remote `federation.activityId`, resolved from `postId`, and
298
+ * delivered ONLY to that origin author's inbox (never fanned out to followers).
299
+ * The AP activity id is minted deterministically from the native Like doc's `_id`
300
+ * so the `Undo` re-mints the same id without persisting it.
301
+ */
302
+ export interface LocalLikeEventPayload {
303
+ /** The native `Like` document `_id` — the deterministic AP Like activity id. */
304
+ _id: unknown;
305
+ /** The liked post's local `_id` — resolved to its canonical AP object id + author inbox. */
306
+ postId: string;
307
+ }
308
+
309
+ /**
310
+ * A local domain event handed to connectors for outbound delivery. Discriminated
311
+ * by `kind`: post lifecycle (`post.create` / `post.update` / `post.delete`),
312
+ * engagement (`post.boost` / `post.unboost` / `post.like` / `post.unlike`), actor
313
+ * profile changes (`actor.update`), and the follow lifecycle.
314
+ *
315
+ * Generic over the app's post-content type `TContent`, carried by the
316
+ * `post.create` / `post.update` payloads.
317
+ */
318
+ export type LocalNetworkEvent<TContent = unknown> =
319
+ | {
320
+ kind: 'post.create';
321
+ post: LocalPostEventPayload<TContent>;
322
+ actorOxyUserId: string;
323
+ actorUsername: string;
324
+ }
325
+ | {
326
+ kind: 'post.boost';
327
+ boost: LocalBoostEventPayload;
328
+ actorOxyUserId: string;
329
+ actorUsername: string;
330
+ }
331
+ | {
332
+ kind: 'post.unboost';
333
+ boost: LocalBoostEventPayload;
334
+ actorOxyUserId: string;
335
+ actorUsername: string;
336
+ }
337
+ | {
338
+ kind: 'post.update';
339
+ post: LocalPostEventPayload<TContent>;
340
+ actorOxyUserId: string;
341
+ actorUsername: string;
342
+ }
343
+ | {
344
+ kind: 'post.delete';
345
+ post: LocalDeleteEventPayload;
346
+ actorOxyUserId: string;
347
+ actorUsername: string;
348
+ }
349
+ | {
350
+ kind: 'post.like';
351
+ like: LocalLikeEventPayload;
352
+ actorOxyUserId: string;
353
+ actorUsername: string;
354
+ }
355
+ | {
356
+ kind: 'post.unlike';
357
+ like: LocalLikeEventPayload;
358
+ actorOxyUserId: string;
359
+ actorUsername: string;
360
+ }
361
+ | {
362
+ /**
363
+ * A local user changed an actor-visible profile field OWNED by the app (e.g.
364
+ * a `profileHeaderImage` banner). The connector rebroadcasts the FULL actor
365
+ * document as an `Update(Person)` to remote followers so Mastodon refreshes.
366
+ * Oxy-owned fields (displayName/avatar/bio) are NOT hooked here — they change
367
+ * in Oxy, which has no signal into the app (see `federateActorUpdate`).
368
+ */
369
+ kind: 'actor.update';
370
+ actorOxyUserId: string;
371
+ actorUsername: string;
372
+ }
373
+ | {
374
+ kind: 'follow.add';
375
+ localOxyUserId: string;
376
+ localUsername: string;
377
+ targetActorUri: string;
378
+ }
379
+ | {
380
+ kind: 'follow.remove';
381
+ localOxyUserId: string;
382
+ localUsername: string;
383
+ targetActorUri: string;
384
+ };
385
+
386
+ /** Context passed alongside an inbound payload to {@link NetworkConnector.receive}. */
387
+ export interface ReceiveContext {
388
+ /** The remote actor URI/DID whose signature was already verified by the transport. */
389
+ verifiedActorUri: string;
390
+ }
391
+
392
+ /**
393
+ * The common contract every external network speaks behind. A connector owns all
394
+ * protocol specifics; the registry and the app's content core only ever see this
395
+ * surface.
396
+ *
397
+ * Generic over the app's post-content type `TContent`, which flows through
398
+ * {@link NetworkConnector.deliver} on `post.create` / `post.update` events.
399
+ */
400
+ export interface NetworkConnector<TContent = unknown> {
401
+ /** The network this connector serves. */
402
+ readonly id: NetworkId;
403
+ /** Whether this connector is enabled (env-gated). Disabled connectors are skipped. */
404
+ readonly enabled: boolean;
405
+ /** True when `subject` (a handle / URI / DID) belongs to this network. */
406
+ matches(subject: string): boolean;
407
+ /** Resolve a handle to a normalized actor (webfinger for AP, handle→DID for atproto). */
408
+ resolve(handle: string): Promise<NormalizedExternalActor | null>;
409
+ /** Fetch + normalize an actor profile by its protocol id. */
410
+ fetchProfile(externalId: string): Promise<NormalizedExternalActor | null>;
411
+ /** Backfill + normalize an actor's recent posts. */
412
+ fetchPosts(externalId: string, opts?: FetchPostsOptions): Promise<FetchPostsResult>;
413
+ /** Deliver a local domain event outbound (federate to followers / write a record). */
414
+ deliver(event: LocalNetworkEvent<TContent>): Promise<void>;
415
+ /** Process an inbound payload (already actor-verified by the transport). */
416
+ receive(payload: unknown, ctx: ReceiveContext): Promise<void>;
417
+ /** Resolve/mint the Oxy user this external actor maps to; null when unresolvable. */
418
+ mapIdentity(actor: NormalizedExternalActor): Promise<string | null>;
419
+ }