@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,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
|
+
}
|