@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,107 @@
1
+ /**
2
+ * ActivityPub URI parsing + host canonicalisation + per-instance domain policy.
3
+ *
4
+ * `canonicalFederationHost` / `isSameFederationHost` are the one rule for "are
5
+ * these the same host", and every domain comparison the policy makes is built
6
+ * out of them. `extractActorUriFromActivityId` is pure and domain-agnostic. The
7
+ * blocked-domain check and the local-post-id extractor are DOMAIN-SCOPED — they
8
+ * depend on which hosts an app mints its own URIs under and which identity apex
9
+ * publishes its own users — so they come from a per-instance
10
+ * {@link createDomainPolicy} rather than a module-level constant.
11
+ */
12
+ /**
13
+ * THE FORM THIS ENGINE COMPARES HOSTS IN — trimmed, lowercased, one leading
14
+ * `www.` removed, and nothing else.
15
+ *
16
+ * It is exported because it is not an implementation detail: it decides whether
17
+ * two spellings of a host are the SAME host, and {@link createDomainPolicy} —
18
+ * the blocked-domain gate every inbound activity and every actor fetch passes
19
+ * through — is built out of this exact function. A consumer that keeps its own
20
+ * copy of the rule (a moderation blocklist, a transparency page, a content
21
+ * purge) is keeping a second opinion about which hosts are which, and the moment
22
+ * the two drift the consumer acts on domains the engine never refused. For a
23
+ * consumer whose action is irreversible that difference is deleted content.
24
+ *
25
+ * WHAT IT DELIBERATELY DOES NOT DO
26
+ *
27
+ * It does not strip a TRAILING DOT. `example.com.` is the fully-qualified
28
+ * spelling of `example.com` in DNS, but it is a different string here — and
29
+ * also on the wire, because `new URL('https://example.com./x').hostname`
30
+ * preserves the dot and that value is what the engine feeds in. So the two
31
+ * spellings do not match each other, in this function and in the engine
32
+ * alike. Widening that is a POLICY decision (it makes a blocklist match hosts
33
+ * it does not literally name) and belongs to whoever owns the policy, not to
34
+ * a string transform.
35
+ *
36
+ * It does not perform IDNA. The input is expected to be an ASCII host in the
37
+ * form the WHATWG URL parser produces — `new URL(...).hostname` has already
38
+ * applied ToASCII, so an internationalised host arrives as punycode
39
+ * (`xn--ber-goa.example`). A host spelled in unicode is lowercased but NOT
40
+ * converted, so it will not match its own punycode wire form. Callers that
41
+ * accept operator-typed hosts must convert them before comparing.
42
+ *
43
+ * @param host a bare host — no scheme, no port, no path.
44
+ */
45
+ export declare function canonicalFederationHost(host: string): string;
46
+ /**
47
+ * Whether two spellings name the same host under {@link canonicalFederationHost}.
48
+ *
49
+ * This is the question a caller actually has ("is the host on this activity the
50
+ * host we blocked?"), and it exists so that asking it does not require each
51
+ * caller to assemble its own comparison around the normaliser. Assembling one is
52
+ * where the mistakes happen, and they are quiet ones: a comparison that
53
+ * lowercases but forgets `www.`, or that allows `www.` on one side only and so
54
+ * answers differently depending on argument order, looks correct at every call
55
+ * site and is wrong for exactly the hosts an evasive instance will use.
56
+ *
57
+ * A blank string names no host, so it matches nothing — including another blank.
58
+ * That is the same answer {@link DomainPolicy.isBlockedDomain} gives it: a host
59
+ * that is not named is not in any set.
60
+ */
61
+ export declare function isSameFederationHost(a: string, b: string): boolean;
62
+ /**
63
+ * Given an ActivityPub activity/object ID (URL), extract the actor URI by
64
+ * trimming everything from the first recognised post-path segment onward.
65
+ *
66
+ * e.g. "https://mastodon.social/users/alice/statuses/12345"
67
+ * → "https://mastodon.social/users/alice"
68
+ *
69
+ * Returns null when the URL is malformed or no post-path segment is found.
70
+ */
71
+ export declare function extractActorUriFromActivityId(activityId: string): string | null;
72
+ /** Configuration for a per-instance {@link DomainPolicy}. */
73
+ export interface DomainPolicyConfig {
74
+ /** The app's federation domain (where it mints webfinger / inbox / collection URIs). */
75
+ domain: string;
76
+ /** The host that owns actor URIs; defaults to `domain`. */
77
+ actorDomain?: string;
78
+ /**
79
+ * Oxy's identity apex (e.g. `oxy.so`). Every Oxy/Mention user is ALSO published
80
+ * as `acct:<username>@<apex>` via the DID layer, so an actor on this host is one
81
+ * of OUR OWN users — resolving it as remote would create duplicate actor rows
82
+ * for local users. Blocked when set.
83
+ */
84
+ identityApex?: string;
85
+ /** Additional explicitly-blocked domains (case-insensitive). */
86
+ blockedDomains?: Iterable<string>;
87
+ }
88
+ /** Per-instance domain policy: which hosts are ours/blocked, and our own post-URI shape. */
89
+ export interface DomainPolicy {
90
+ /**
91
+ * True when a domain should be rejected for federation — our own ActivityPub
92
+ * domains, the Oxy identity apex (both publish our own users), or an explicitly
93
+ * configured blocked domain.
94
+ */
95
+ isBlockedDomain(domain: string): boolean;
96
+ /**
97
+ * Extract a local Post id from an ActivityPub object URI that points at one of
98
+ * our own posts (`https://<our-domain>/ap/users/<username>/posts/<postId>`).
99
+ * Returns null when the URI host is not one of ours or the path does not match
100
+ * the canonical scheme (the object is remote, resolved by activityId instead).
101
+ */
102
+ extractLocalPostId(objectUri: string): string | null;
103
+ }
104
+ /**
105
+ * Build the per-instance {@link DomainPolicy} from an app's domain configuration.
106
+ */
107
+ export declare function createDomainPolicy(config: DomainPolicyConfig): DomainPolicy;
@@ -0,0 +1,113 @@
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
+ /** The signature algorithm parameter emitted in (and expected on) the `Signature` header. */
25
+ export declare const HTTP_SIGNATURE_ALGORITHM = "rsa-sha256";
26
+ /**
27
+ * The default content-type folded into the signing string for body-bearing
28
+ * requests. ActivityPub delivery signs `content-type` (some servers — e.g.
29
+ * Threads — require it), and the AP content type is always
30
+ * `application/activity+json`.
31
+ */
32
+ export declare const DEFAULT_SIGNED_CONTENT_TYPE = "application/activity+json";
33
+ /**
34
+ * Signs an already-composed signing string with the private key backing `keyId`
35
+ * and returns the base64 RSA-SHA256 signature. The private key custody lives
36
+ * behind this function — for Mention it delegates to oxy-api's
37
+ * `POST /federation/sign` so the key never leaves Oxy.
38
+ */
39
+ export type HttpSignatureSigner = (keyId: string, signingString: string) => Promise<string>;
40
+ /** Options controlling the signing-string composition (all optional). */
41
+ export interface SignRequestOptions {
42
+ /**
43
+ * The content-type value included in the signing string (and covered by the
44
+ * signature) for body-bearing requests. Defaults to
45
+ * {@link DEFAULT_SIGNED_CONTENT_TYPE}. The Content-Type request HEADER itself is
46
+ * set by the deliverer's fetch, not returned here.
47
+ */
48
+ contentType?: string;
49
+ }
50
+ /**
51
+ * Build the HTTP Signature header per draft-cavage-http-signatures-12 and sign it
52
+ * via the injected {@link HttpSignatureSigner} (the private key never enters this
53
+ * package).
54
+ *
55
+ * The spec-correct signing string is composed locally: `(request-target)`, host,
56
+ * date, and — for body-bearing requests — digest and content-type. The composed
57
+ * string is handed to `sign`, and the resulting signature is assembled into the
58
+ * `Signature:` header.
59
+ *
60
+ * Returns the headers to attach to the outbound request (Host, Date, optional
61
+ * Digest, and Signature). Content-Type is set by the deliverer's fetch.
62
+ */
63
+ export declare function signRequest(sign: HttpSignatureSigner, keyId: string, method: string, url: string, body?: string, options?: SignRequestOptions): Promise<Record<string, string>>;
64
+ /** An inbound request reduced to what signature verification needs. */
65
+ export interface VerifyHttpRequest {
66
+ method: string;
67
+ path: string;
68
+ headers: Record<string, string | string[] | undefined>;
69
+ body?: unknown;
70
+ }
71
+ /** The verdict of {@link verifyHttpSignature}. */
72
+ export interface VerifyHttpResult {
73
+ verified: boolean;
74
+ actorUri?: string;
75
+ reason?: string;
76
+ }
77
+ /**
78
+ * Resolve a `keyId` to its public key PEM and the actor URI that owns it, or
79
+ * `null` when the key cannot be fetched (a failed fetch fails verification).
80
+ */
81
+ export type FetchPublicKey = (keyId: string) => Promise<{
82
+ publicKeyPem: string;
83
+ actorUri: string;
84
+ } | null>;
85
+ /** Options controlling inbound signature verification. */
86
+ export interface VerifyHttpSignatureOptions {
87
+ /**
88
+ * When `true`, reconstruct the signed `host` line from `X-Forwarded-Host`
89
+ * (first comma token) instead of `Host` when the header is present.
90
+ *
91
+ * Load-bearing for an edge-proxied apex: when a CDN/edge rewrites the origin
92
+ * `Host` (e.g. `mention.earth` → `api.mention.earth`) and forwards the ORIGINAL
93
+ * signed host in `X-Forwarded-Host`, the verifier must rebuild the `host`
94
+ * signing line from it or the reconstructed string never matches what the
95
+ * sender signed. A proxy chain may append a comma-separated list whose FIRST
96
+ * token is the client-facing host. This grants a forger nothing: the signature
97
+ * cryptographically binds whatever host value the sender signed, so a bogus
98
+ * `X-Forwarded-Host` simply fails verification. Falls back to `host` when the
99
+ * header is absent (direct delivery). Defaults to `false` (trust only `Host`).
100
+ */
101
+ trustForwardedHost?: boolean;
102
+ /**
103
+ * Optional sink for non-fatal diagnostics (key-fetch failure, verify
104
+ * exception). No-op when omitted. Kept out of the return value so verdicts
105
+ * stay data-only.
106
+ */
107
+ onDebug?: (message: string, detail?: unknown) => void;
108
+ }
109
+ /**
110
+ * Verify the HTTP signature on an incoming request.
111
+ * Returns the actor URI (key owner) if valid, null otherwise.
112
+ */
113
+ export declare function verifyHttpSignature(req: VerifyHttpRequest, fetchPublicKey: FetchPublicKey, options?: VerifyHttpSignatureOptions): Promise<VerifyHttpResult>;
@@ -0,0 +1,336 @@
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
+ * HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
25
+ * ActivityPub federation shares. Private-key custody is injected; the key never
26
+ * enters this package.
27
+ */
28
+ export { signRequest, verifyHttpSignature, HTTP_SIGNATURE_ALGORITHM, DEFAULT_SIGNED_CONTENT_TYPE, type HttpSignatureSigner, type SignRequestOptions, type VerifyHttpRequest, type VerifyHttpResult, type FetchPublicKey, type VerifyHttpSignatureOptions, } from './httpSignature';
29
+ /**
30
+ * Domain-parameterized ActivityPub URL builders — each app instantiates them once
31
+ * with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
32
+ */
33
+ export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, type UrlBuilders } from './urls';
34
+ /**
35
+ * Network identity: the MECHANISM for re-labelling an account republished by a
36
+ * bridge onto the network it actually came from, plus the network vocabulary and
37
+ * the bidirectional upstream-profile-URL rule.
38
+ *
39
+ * The mechanism is here; the ENTRIES are not, and must not be. Which operators
40
+ * may be trusted to re-attribute somebody's account is a moderation judgement an
41
+ * app commits and answers for — `createBridgeRelabeller` takes them as a
42
+ * parameter so no app inherits another's.
43
+ */
44
+ export { FEDERATION_NETWORKS, BSKY_NETWORK_DOMAIN, blueskyUsernameFromHandle, createBridgeRelabeller, stripBridgeBoilerplate, upstreamProfileUrl, parseUpstreamProfileUrl, federatedUsernameFromUpstreamUrl, upstreamHandleFromProfileField, upstreamHandleFromAlsoKnownAs, upstreamHandleFromAutomatedActor, upstreamHandleFromPreferredUsername, upstreamHandleFromProxyOf, readProxyDeclarations, type FederationNetwork, type FederationBridgeEntry, type BridgeRelabeller, type BridgeConsentModel, type BridgeDerivation, type BridgedActorField, type DeriveNetworkIdentity, type NetworkIdentity, type NetworkIdentityCandidate, type ProxyDeclaration, } from './networkIdentity';
45
+ /**
46
+ * The shared JSON-LD `@context` (load-bearing term declarations) and the
47
+ * ActivityPub URI helpers (actor-uri extraction + the per-instance domain policy:
48
+ * blocked-domain check + local-post-id extraction).
49
+ *
50
+ * `canonicalFederationHost` / `isSameFederationHost` are exported because the
51
+ * domain policy is not the only thing that has to decide whether two spellings
52
+ * are the same host: a moderation blocklist, a transparency page and a content
53
+ * purge all ask the same question about the same hosts, and any of them keeping
54
+ * its own copy of the rule is a second opinion waiting to diverge from the one
55
+ * the engine enforces. They are the very functions {@link createDomainPolicy} is
56
+ * built from — not a parallel implementation that agrees today.
57
+ */
58
+ export { AP_CONTEXT } from './apContext';
59
+ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActivityId, createDomainPolicy, type DomainPolicy, type DomainPolicyConfig, } from './apUri';
60
+ /**
61
+ * The single builder of a LOCAL user's ActivityPub actor document —
62
+ * byte-identical across apps, with media resolution injected. The actor `type`
63
+ * follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
64
+ */
65
+ export { createLocalActorBuilder, localActorTypeForAccountKind, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
66
+ /** Supported external networks. */
67
+ export type NetworkId = 'activitypub' | 'atproto';
68
+ /**
69
+ * A remote actor normalized into a network-neutral shape. Built by a connector
70
+ * from its protocol's profile representation, and consumed by the identity
71
+ * bridge ({@link NetworkConnector.mapIdentity}) to resolve/mint the federated
72
+ * Oxy user the actor maps to.
73
+ */
74
+ export interface NormalizedExternalActor {
75
+ network: NetworkId;
76
+ /** Stable protocol id: an ActivityPub actor URI, or an atproto DID. */
77
+ externalId: string;
78
+ /** Fediverse-style handle (`user@domain` for AP; the atproto handle/DID otherwise). */
79
+ handle: string;
80
+ /**
81
+ * The canonical `local@domain` username this actor is stored under in Oxy — the
82
+ * exact value passed to `PUT /users/resolve`. Each connector derives it for its
83
+ * own protocol so the shared identity bridge never has to guess: AP uses the
84
+ * acct (`user@domain`); atproto synthesizes `<username>@<instance-domain>`, where
85
+ * a default Bluesky handle drops the redundant `.bsky.social` suffix
86
+ * (`skylee1.bsky.social` → `skylee1@bsky.social`) and a custom domain keeps its
87
+ * whole handle (`mayor.nyc.gov` → `mayor.nyc.gov@bsky.social`). It MUST equal
88
+ * `instanceDomain` after the `@` so oxy-api's username↔domain binding holds.
89
+ */
90
+ federatedUsername: string;
91
+ /**
92
+ * The instance/origin domain this actor's identity belongs to — the `domain`
93
+ * passed to `PUT /users/resolve` and stamped on imported `Post.instanceDomain`.
94
+ * AP: the actor host (e.g. `mastodon.social`); atproto: the handle's parent
95
+ * domain (e.g. `bsky.social`), since a DID carries no host.
96
+ */
97
+ instanceDomain: string;
98
+ displayName?: string;
99
+ avatarUrl?: string;
100
+ bannerUrl?: string;
101
+ bio?: string;
102
+ followersCount?: number;
103
+ followingCount?: number;
104
+ postsCount?: number;
105
+ /** The Oxy user this actor resolves to, once known. */
106
+ oxyUserId?: string;
107
+ }
108
+ /** A single media item on a normalized external post (mirrors the Post media shape). */
109
+ export interface NormalizedExternalMedia {
110
+ id: string;
111
+ type: 'image' | 'video';
112
+ remoteUrl?: string;
113
+ alt?: string;
114
+ width?: number;
115
+ height?: number;
116
+ durationSec?: number;
117
+ orientation?: 'portrait' | 'landscape' | 'square';
118
+ aspectRatio?: number;
119
+ }
120
+ /**
121
+ * A remote post normalized into a network-neutral shape. Mirrors the
122
+ * `Post.federation` provenance block plus the author and media a connector
123
+ * resolves while importing it.
124
+ */
125
+ export interface NormalizedExternalPost {
126
+ network: NetworkId;
127
+ /** Globally-unique provenance id (AP activity/object id, or atproto at:// URI). */
128
+ activityId: string;
129
+ /** Authoring actor's protocol id (AP actor URI / atproto DID). */
130
+ actorUri: string;
131
+ url?: string;
132
+ inReplyTo?: string;
133
+ sensitive?: boolean;
134
+ spoilerText?: string;
135
+ /** Resolved Oxy author, when the actor already maps to an Oxy user. */
136
+ authorOxyUserId?: string;
137
+ text: string;
138
+ media?: NormalizedExternalMedia[];
139
+ hashtags?: string[];
140
+ /**
141
+ * Resolved @mention Oxy user ids — the stored `mentions` allowlist, keyed by the
142
+ * `[mention:<id>]` placeholders the connector rewrote into {@link text}.
143
+ */
144
+ mentions?: string[];
145
+ /**
146
+ * The quoted post's external URI (an atproto `at://` URI / an AP quote uri) when
147
+ * this post quotes another. Resolved to a local `quoteOf` Post id at import time
148
+ * by matching an imported post's `federation.activityId`; left unresolved (no
149
+ * quote link) when the quoted post is not imported locally.
150
+ */
151
+ quotedUri?: string;
152
+ language?: string;
153
+ languages?: string[];
154
+ createdAt?: Date;
155
+ }
156
+ /** Options for paging a connector's post fetch. */
157
+ export interface FetchPostsOptions {
158
+ limit?: number;
159
+ cursor?: string;
160
+ }
161
+ /** Result of a connector post fetch (opaque per-connector cursor). */
162
+ export interface FetchPostsResult {
163
+ posts: NormalizedExternalPost[];
164
+ cursor?: string;
165
+ }
166
+ /**
167
+ * Local-post shape a `post.create` event carries to outbound delivery.
168
+ *
169
+ * `content` is the consuming app's CANONICAL post-content type (`TContent`), not
170
+ * a trimmed-down copy: a connector needs the post's localized variants and
171
+ * primary language to declare the post's language on the wire (ActivityPub
172
+ * `contentMap`, atproto `langs`), and a narrowed structural type here would
173
+ * silently DROP them at the seam. The federation package never inspects
174
+ * `content`; it flows through untouched to the app's own connector.
175
+ */
176
+ export interface LocalPostEventPayload<TContent = unknown> {
177
+ _id: unknown;
178
+ content: TContent;
179
+ hashtags?: string[];
180
+ mentions?: string[];
181
+ /** The classifier's resolved primary language — the fallback when the author declared no primary tag. */
182
+ language?: string;
183
+ visibility: string;
184
+ createdAt: string;
185
+ /**
186
+ * The boosted original's local Post `_id` when this post is a boost
187
+ * (`type: 'boost'`). A boost carries an intentionally EMPTY body and MUST NOT
188
+ * federate as a `Create(Note)` — the connector re-routes it to an `Announce`.
189
+ * Preserving it through the seam is what lets `POST /posts` `boost_of` avoid
190
+ * emitting a blank Create.
191
+ */
192
+ boostOf?: string | null;
193
+ /**
194
+ * The parent's local Post `_id` when this post is a REPLY. The connector emits
195
+ * the Note with `inReplyTo` (the parent's canonical AP object id) + a
196
+ * parent-author `Mention`, and unions the parent author's inbox into delivery so
197
+ * a reply to a remote post threads and notifies its author. Preserving it through
198
+ * the seam is what lets the `/feed/reply` path federate replies. Absent for a
199
+ * top-level post.
200
+ */
201
+ parentPostId?: string | null;
202
+ }
203
+ /**
204
+ * The minimal boost shape a `post.boost` / `post.unboost` event carries to
205
+ * outbound delivery. A boost has no body of its own; the connector federates it
206
+ * as an `Announce` (or `Undo(Announce)`) of the original post's canonical AP id,
207
+ * resolved from `boostOf`. `createdAt` stamps the activity's `published`.
208
+ */
209
+ export interface LocalBoostEventPayload {
210
+ _id: unknown;
211
+ boostOf: string;
212
+ createdAt: string | Date;
213
+ }
214
+ /**
215
+ * The minimal shape a `post.delete` event carries. A local post's canonical AP
216
+ * object id is minted deterministically from the deleter's username + this `_id`
217
+ * (`https://<domain>/ap/users/<username>/posts/<_id>`), so the connector needs
218
+ * nothing more to emit a `Delete(Tombstone)`. The post row is already gone by the
219
+ * time this fires — the id is captured BEFORE deletion.
220
+ */
221
+ export interface LocalDeleteEventPayload {
222
+ _id: unknown;
223
+ }
224
+ /**
225
+ * The shape a `post.like` / `post.unlike` event carries to outbound delivery.
226
+ * A federated-post like federates as a `Like` (or `Undo(Like)`) whose `object` is
227
+ * the liked original's remote `federation.activityId`, resolved from `postId`, and
228
+ * delivered ONLY to that origin author's inbox (never fanned out to followers).
229
+ * The AP activity id is minted deterministically from the native Like doc's `_id`
230
+ * so the `Undo` re-mints the same id without persisting it.
231
+ */
232
+ export interface LocalLikeEventPayload {
233
+ /** The native `Like` document `_id` — the deterministic AP Like activity id. */
234
+ _id: unknown;
235
+ /** The liked post's local `_id` — resolved to its canonical AP object id + author inbox. */
236
+ postId: string;
237
+ }
238
+ /**
239
+ * A local domain event handed to connectors for outbound delivery. Discriminated
240
+ * by `kind`: post lifecycle (`post.create` / `post.update` / `post.delete`),
241
+ * engagement (`post.boost` / `post.unboost` / `post.like` / `post.unlike`), actor
242
+ * profile changes (`actor.update`), and the follow lifecycle.
243
+ *
244
+ * Generic over the app's post-content type `TContent`, carried by the
245
+ * `post.create` / `post.update` payloads.
246
+ */
247
+ export type LocalNetworkEvent<TContent = unknown> = {
248
+ kind: 'post.create';
249
+ post: LocalPostEventPayload<TContent>;
250
+ actorOxyUserId: string;
251
+ actorUsername: string;
252
+ } | {
253
+ kind: 'post.boost';
254
+ boost: LocalBoostEventPayload;
255
+ actorOxyUserId: string;
256
+ actorUsername: string;
257
+ } | {
258
+ kind: 'post.unboost';
259
+ boost: LocalBoostEventPayload;
260
+ actorOxyUserId: string;
261
+ actorUsername: string;
262
+ } | {
263
+ kind: 'post.update';
264
+ post: LocalPostEventPayload<TContent>;
265
+ actorOxyUserId: string;
266
+ actorUsername: string;
267
+ } | {
268
+ kind: 'post.delete';
269
+ post: LocalDeleteEventPayload;
270
+ actorOxyUserId: string;
271
+ actorUsername: string;
272
+ } | {
273
+ kind: 'post.like';
274
+ like: LocalLikeEventPayload;
275
+ actorOxyUserId: string;
276
+ actorUsername: string;
277
+ } | {
278
+ kind: 'post.unlike';
279
+ like: LocalLikeEventPayload;
280
+ actorOxyUserId: string;
281
+ actorUsername: string;
282
+ } | {
283
+ /**
284
+ * A local user changed an actor-visible profile field OWNED by the app (e.g.
285
+ * a `profileHeaderImage` banner). The connector rebroadcasts the FULL actor
286
+ * document as an `Update(Person)` to remote followers so Mastodon refreshes.
287
+ * Oxy-owned fields (displayName/avatar/bio) are NOT hooked here — they change
288
+ * in Oxy, which has no signal into the app (see `federateActorUpdate`).
289
+ */
290
+ kind: 'actor.update';
291
+ actorOxyUserId: string;
292
+ actorUsername: string;
293
+ } | {
294
+ kind: 'follow.add';
295
+ localOxyUserId: string;
296
+ localUsername: string;
297
+ targetActorUri: string;
298
+ } | {
299
+ kind: 'follow.remove';
300
+ localOxyUserId: string;
301
+ localUsername: string;
302
+ targetActorUri: string;
303
+ };
304
+ /** Context passed alongside an inbound payload to {@link NetworkConnector.receive}. */
305
+ export interface ReceiveContext {
306
+ /** The remote actor URI/DID whose signature was already verified by the transport. */
307
+ verifiedActorUri: string;
308
+ }
309
+ /**
310
+ * The common contract every external network speaks behind. A connector owns all
311
+ * protocol specifics; the registry and the app's content core only ever see this
312
+ * surface.
313
+ *
314
+ * Generic over the app's post-content type `TContent`, which flows through
315
+ * {@link NetworkConnector.deliver} on `post.create` / `post.update` events.
316
+ */
317
+ export interface NetworkConnector<TContent = unknown> {
318
+ /** The network this connector serves. */
319
+ readonly id: NetworkId;
320
+ /** Whether this connector is enabled (env-gated). Disabled connectors are skipped. */
321
+ readonly enabled: boolean;
322
+ /** True when `subject` (a handle / URI / DID) belongs to this network. */
323
+ matches(subject: string): boolean;
324
+ /** Resolve a handle to a normalized actor (webfinger for AP, handle→DID for atproto). */
325
+ resolve(handle: string): Promise<NormalizedExternalActor | null>;
326
+ /** Fetch + normalize an actor profile by its protocol id. */
327
+ fetchProfile(externalId: string): Promise<NormalizedExternalActor | null>;
328
+ /** Backfill + normalize an actor's recent posts. */
329
+ fetchPosts(externalId: string, opts?: FetchPostsOptions): Promise<FetchPostsResult>;
330
+ /** Deliver a local domain event outbound (federate to followers / write a record). */
331
+ deliver(event: LocalNetworkEvent<TContent>): Promise<void>;
332
+ /** Process an inbound payload (already actor-verified by the transport). */
333
+ receive(payload: unknown, ctx: ReceiveContext): Promise<void>;
334
+ /** Resolve/mint the Oxy user this external actor maps to; null when unresolvable. */
335
+ mapIdentity(actor: NormalizedExternalActor): Promise<string | null>;
336
+ }