@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,136 @@
1
+ /**
2
+ * `@oxy.so/federation/node` — the runnable Node/Express federation engine.
3
+ *
4
+ * A SEPARATE subpath from the package root so this Node-only code never enters
5
+ * isomorphic bundles that import `@oxy.so/federation`.
6
+ *
7
+ * Phase 2 (HTTP signatures): the signed-fetch transport — a signed ActivityPub
8
+ * GET with per-hop HTTP-signature re-signing, built over an app-injected
9
+ * SSRF-safe single-hop transport. The pure sign/verify crypto it drives lives in
10
+ * the isomorphic `.` entry.
11
+ *
12
+ * Phase 3 (actor model + resolution): the identity bridge + remote-actor resolver.
13
+ *
14
+ * Phase 4 (delivery + follow lifecycle + routers + inbound dispatch): the outbound
15
+ * delivery transport + follow protocol, the inbound dispatcher (Follow/Accept/
16
+ * Undo(Follow)/Reject, delegating content verbs to the app), and the webfinger +
17
+ * actor + inbox + follow-graph Express routers.
18
+ */
19
+
20
+ export {
21
+ createSignedFetch,
22
+ type SignedFetch,
23
+ type CreateSignedFetchConfig,
24
+ type SingleHopFetch,
25
+ type SingleHopFetchInit,
26
+ type SignedFetchLogger,
27
+ } from './signedFetch';
28
+
29
+ /**
30
+ * The actor↔Oxy-user identity bridge — the default implementation of the
31
+ * `PUT /users/resolve` + actor-gone archive/delete seam over an injected
32
+ * service-request transport.
33
+ */
34
+ export {
35
+ createIdentityBridge,
36
+ type IdentityBridge,
37
+ type IdentityBridgeConfig,
38
+ type IdentityBridgeLogger,
39
+ type ServiceRequest,
40
+ type ServiceRequestMethod,
41
+ type ReportActorGoneOutcome,
42
+ type DeleteActorIdentityOutcome,
43
+ } from './identityBridge';
44
+
45
+ /**
46
+ * Remote-actor resolution/caching/refresh (webfinger, signed actor fetch,
47
+ * 410-Gone tombstone) over a bring-your-own-store adapter, the identity bridge,
48
+ * and injected transports + text normalization.
49
+ */
50
+ export {
51
+ createActorResolver,
52
+ ActorResolver,
53
+ type ActorResolverConfig,
54
+ type ActorResolverIdentity,
55
+ type ActorResolverLogger,
56
+ type ActorTextAdapter,
57
+ type FederatedActorStore,
58
+ type FederatedActorRecordBase,
59
+ type FederatedActorUpsert,
60
+ type FederatedActorField,
61
+ type WebFingerFetch,
62
+ type WebFingerJrd,
63
+ } from './actorResolver';
64
+
65
+ /**
66
+ * Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
67
+ * Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
68
+ * custody, an SSRF-safe delivery transport, and bring-your-own-store adapters.
69
+ */
70
+ export {
71
+ createDeliveryService,
72
+ type DeliveryService,
73
+ type DeliveryServiceConfig,
74
+ type DeliveryLogger,
75
+ type DeliveryKeys,
76
+ type DeliverSingleHop,
77
+ type DeliverSingleHopInit,
78
+ type DeliverSingleHopResult,
79
+ type DeliveryResponseStream,
80
+ type DeliveryTransport,
81
+ type DeliveryQueueJob,
82
+ type DeliveryFallbackQueue,
83
+ type DeliveryActorFields,
84
+ type DeliveryActorStore,
85
+ type DeliveryFollowStore,
86
+ type DeliveryActorRefresh,
87
+ type DeliveryConsent,
88
+ type DeliveryActorProfile,
89
+ type DeliveryIdentity,
90
+ type DeliveryProfile,
91
+ type SafeUrlVerdict,
92
+ } from './delivery';
93
+
94
+ /**
95
+ * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
96
+ * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
97
+ * identity + store adapters, and the `onContentActivity` seam every content verb
98
+ * (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
99
+ */
100
+ export {
101
+ createInboundDispatcher,
102
+ ActorResolutionPendingError,
103
+ type InboundDispatcher,
104
+ type InboundDispatcherConfig,
105
+ type InboundDispatcherLogger,
106
+ type InboundActivityValidation,
107
+ type InboundLocalUser,
108
+ type InboundIdentity,
109
+ type InboundConsent,
110
+ type InboundActorResolver,
111
+ type InboundFollowStore,
112
+ type InboundDelivery,
113
+ } from './inboundDispatch';
114
+
115
+ /** The WebFinger + host-meta discovery router (domain-parameterized, consent-gated). */
116
+ export {
117
+ createWebfingerRouter,
118
+ type WebfingerRouterConfig,
119
+ type WebfingerSharingState,
120
+ type WebfingerUser,
121
+ type WebfingerJrd,
122
+ type WebfingerLogger,
123
+ } from './webfingerRouter';
124
+
125
+ /**
126
+ * The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
127
+ * `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
128
+ */
129
+ export {
130
+ createActorRouter,
131
+ type ActorRouterConfig,
132
+ type ActorRouteUser,
133
+ type ActorSharingState,
134
+ type ActorRouterLogger,
135
+ type FollowPage,
136
+ } from './actorRouter';
@@ -0,0 +1,177 @@
1
+ /**
2
+ * `signedFetch` — a signed ActivityPub GET with per-hop HTTP-signature
3
+ * re-signing, built over an injected SSRF-safe single-hop transport.
4
+ *
5
+ * WHY A FACTORY OVER AN INJECTED TRANSPORT (not core `safeFetch` directly)
6
+ * -----------------------------------------------------------------------
7
+ * An HTTP signature is bound to the `(request-target)`/`host` of ONE specific
8
+ * URL, so on a redirect the signature MUST be recomputed for the new target.
9
+ * `@oxy.so/core/server`'s `safeFetch` follows redirects internally and re-sends
10
+ * the ORIGINAL headers on each hop (it never re-signs, and it destroys redirect
11
+ * bodies), so it cannot back per-hop re-signing. Instead — mirroring how
12
+ * `@oxy.so/protocol/node` injects its `NodeFetch` adapter over `safeFetch` — this
13
+ * factory takes a single-hop transport that validates + IP-pins ONE request and
14
+ * returns the response WITHOUT following redirects. The engine owns the
15
+ * federation policy (signing, the bounded redirect loop that re-signs each hop,
16
+ * the unsigned 5xx fallback); the app supplies the SSRF transport (Mention adapts
17
+ * its `@oxy.so/core/server`-based single-hop fetch), keeping the SSRF/DNS-pin
18
+ * policy in ONE place.
19
+ */
20
+
21
+ import { signRequest, type HttpSignatureSigner } from '../httpSignature';
22
+
23
+ /** Total time budget for a single signed hop (connect + response headers). */
24
+ const SIGNED_FETCH_TIMEOUT_MS = 10000;
25
+ /** Bounded redirect budget for signed AP GETs; each hop is re-validated and re-signed. */
26
+ const SIGNED_FETCH_MAX_REDIRECTS = 3;
27
+ const REDIRECT_STATUS_CODES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
28
+
29
+ /** Per-request options handed to the injected single-hop transport. */
30
+ export interface SingleHopFetchInit {
31
+ /** The EXACT request headers to send (the factory assembles these). */
32
+ headers: Record<string, string>;
33
+ /** Aborts the in-flight request when the signal fires. */
34
+ signal: AbortSignal;
35
+ /** Time-to-first-byte deadline in milliseconds. */
36
+ headersTimeoutMs?: number;
37
+ }
38
+
39
+ /**
40
+ * An SSRF-safe single-hop fetch: it validates + IP-pins the URL and returns the
41
+ * response WITHOUT following redirects (a 3xx is returned as-is so the caller can
42
+ * re-sign the next hop). Mention adapts its `@oxy.so/core/server`-backed
43
+ * `fetchUpstreamSingleHop` into this shape.
44
+ */
45
+ export type SingleHopFetch = (url: string, init: SingleHopFetchInit) => Promise<Response>;
46
+
47
+ /** Non-fatal diagnostics sink for signed fetches. */
48
+ export interface SignedFetchLogger {
49
+ info(message: string): void;
50
+ warn(message: string): void;
51
+ }
52
+
53
+ /** Adapters + config a {@link SignedFetch} is built from. */
54
+ export interface CreateSignedFetchConfig {
55
+ /** RSA-SHA256 signer — private-key custody stays behind this (Mention: oxy-api). */
56
+ sign: HttpSignatureSigner;
57
+ /** Resolve the instance actor's `keyId` used to sign outbound GETs. */
58
+ getInstanceKeyId: () => Promise<string>;
59
+ /** SSRF-safe single-hop transport (does NOT follow redirects). */
60
+ fetchSingleHop: SingleHopFetch;
61
+ /** User-Agent presented to remote servers. */
62
+ userAgent: string;
63
+ /** Optional diagnostics sink (5xx unsigned retry, 401/403 rejection). */
64
+ logger?: SignedFetchLogger;
65
+ }
66
+
67
+ /** A signed ActivityPub GET, returning the standard WHATWG {@link Response}. */
68
+ export type SignedFetch = (url: string, accept: string, init?: RequestInit) => Promise<Response>;
69
+
70
+ function requestInitHeaders(init: RequestInit): Record<string, string> {
71
+ if (!init.headers) return {};
72
+ if (init.headers instanceof Headers) {
73
+ // `Headers.forEach` is typed on the base `DOM` lib, whereas
74
+ // `Headers.entries()` requires `DOM.Iterable` — which this package's build
75
+ // tsconfig deliberately omits (isomorphic node/web split). `forEach` keeps
76
+ // the flatten build-safe under every lib config (Docker + local).
77
+ const flattened: Record<string, string> = {};
78
+ init.headers.forEach((value, key) => {
79
+ flattened[key] = value;
80
+ });
81
+ return flattened;
82
+ }
83
+ if (Array.isArray(init.headers)) return Object.fromEntries(init.headers);
84
+ return init.headers as Record<string, string>;
85
+ }
86
+
87
+ /**
88
+ * Build a `signedFetch(url, accept, init?)`:
89
+ *
90
+ * Signs a GET request using the instance actor key (via the injected signer) and
91
+ * performs it under the SSRF-safe contract (the injected single-hop transport
92
+ * validates the URL AND pins the TCP connection to the validated IP).
93
+ *
94
+ * Redirects are followed manually (bounded by {@link SIGNED_FETCH_MAX_REDIRECTS}),
95
+ * re-validating AND re-signing each hop — an HTTP signature is bound to the
96
+ * `(request-target)`/`host` of a specific URL. When the caller passes
97
+ * `init.redirect === 'manual'`, the redirect `Response` is returned directly so
98
+ * the caller can apply its own stricter redirect policy.
99
+ *
100
+ * Signed for servers that enforce authorized fetch (e.g. Threads). On a 5xx the
101
+ * request is retried unsigned (same SSRF-safe path) as a fallback for public
102
+ * resources.
103
+ */
104
+ export function createSignedFetch(config: CreateSignedFetchConfig): SignedFetch {
105
+ return async function signedFetch(
106
+ url: string,
107
+ accept: string,
108
+ init: RequestInit = {},
109
+ ): Promise<Response> {
110
+ const acceptHeader = `${accept}, application/ld+json; profile="https://www.w3.org/ns/activitystreams"`;
111
+ const keyId = await config.getInstanceKeyId();
112
+ const extraHeaders = requestInitHeaders(init);
113
+ const manualRedirect = init.redirect === 'manual';
114
+
115
+ const fetchOnce = async (targetUrl: string, signed: boolean): Promise<Response> => {
116
+ const sigHeaders = signed ? await signRequest(config.sign, keyId, 'GET', targetUrl) : {};
117
+ return config.fetchSingleHop(targetUrl, {
118
+ headers: {
119
+ Accept: acceptHeader,
120
+ 'User-Agent': config.userAgent,
121
+ ...sigHeaders,
122
+ ...extraHeaders,
123
+ },
124
+ signal: init.signal ?? AbortSignal.timeout(SIGNED_FETCH_TIMEOUT_MS),
125
+ headersTimeoutMs: SIGNED_FETCH_TIMEOUT_MS,
126
+ });
127
+ };
128
+
129
+ const fetchFollowingRedirects = async (
130
+ initialUrl: string,
131
+ signed: boolean,
132
+ ): Promise<{ res: Response; finalUrl: string }> => {
133
+ let currentUrl = initialUrl;
134
+ for (let hop = 0; hop <= SIGNED_FETCH_MAX_REDIRECTS; hop++) {
135
+ const res = await fetchOnce(currentUrl, signed);
136
+ if (!REDIRECT_STATUS_CODES.has(res.status)) {
137
+ return { res, finalUrl: currentUrl };
138
+ }
139
+ // The caller asked to handle redirects itself (stricter per-hop policy).
140
+ if (manualRedirect) {
141
+ return { res, finalUrl: currentUrl };
142
+ }
143
+ const location = res.headers.get('location');
144
+ if (hop === SIGNED_FETCH_MAX_REDIRECTS || !location) {
145
+ return { res, finalUrl: currentUrl };
146
+ }
147
+ currentUrl = new URL(location, currentUrl).toString();
148
+ }
149
+ throw new Error('redirect loop exhausted');
150
+ };
151
+
152
+ const { res, finalUrl } = await fetchFollowingRedirects(url, true);
153
+
154
+ // If the remote server returns a 5xx (e.g. it can't resolve our keyId to
155
+ // verify the signature), retry without the signature as a fallback for public
156
+ // resources. Retry from the post-redirect URL so we don't restart a chain that
157
+ // already landed on the failing hop.
158
+ if (res.status >= 500) {
159
+ config.logger?.info(`[FedSync] signedFetch got ${res.status} for ${finalUrl}, retrying unsigned`);
160
+ return fetchFollowingRedirects(finalUrl, false).then(({ res: unsignedRes }) => unsignedRes);
161
+ }
162
+
163
+ // A 401/403 on a signed request means the remote rejected OUR signature (e.g.
164
+ // it could not resolve/verify our keyId, or our instance key pair is
165
+ // missing/invalid because the service token could not be acquired). Without a
166
+ // log this silently yields zero results — surface it so the failure mode is
167
+ // observable in production. The caller still receives the response and decides
168
+ // how to proceed; we do not change control flow here.
169
+ if (res.status === 401 || res.status === 403) {
170
+ config.logger?.warn(
171
+ `[FedSync] signedFetch got ${res.status} ${res.statusText} for ${url} — remote rejected our HTTP signature (check instance key pair / service token); returning the failed response so no posts are imported from this source`,
172
+ );
173
+ }
174
+
175
+ return res;
176
+ };
177
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The WebFinger + host-meta discovery router.
3
+ *
4
+ * `/.well-known/webfinger` resolves `acct:<user>@<domain>` to the local actor URL;
5
+ * `/.well-known/host-meta(.json)` advertises the WebFinger LRDD template. Both are
6
+ * domain-parameterized (each app answers for its OWN `domain`) and both enforce
7
+ * the fediverse-sharing consent gate — a disabled/unknown user 404s
8
+ * indistinguishably. The JRD cache (Mention: Redis) is injected so the caching
9
+ * strategy stays app-side; the response bytes + the 404-when-off semantics live
10
+ * here so every Oxy app discovers identically.
11
+ *
12
+ * Extracted behaviour-identically from Mention's `wellKnown.routes.ts`.
13
+ */
14
+
15
+ import { Router, type Request, type Response } from 'express';
16
+ import { isSameFederationHost } from '../apUri';
17
+ import type { UrlBuilders } from '../urls';
18
+ import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
19
+
20
+ /** 1 hour, in seconds — the WebFinger JRD cache TTL + response `max-age`. */
21
+ const WEBFINGER_CACHE_TTL = 3600;
22
+ /** 24h — host-meta is effectively static. */
23
+ const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
24
+
25
+ /** The tri-state consent read for a username with no already-resolved user object. */
26
+ export type WebfingerSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
27
+
28
+ /** The resolved-user fields the webfinger consent fallback reads. */
29
+ export interface WebfingerUser {
30
+ _id?: string | null;
31
+ id?: string | null;
32
+ }
33
+
34
+ /** A cached WebFinger JRD document (the `subject` + `links` shape served below). */
35
+ export interface WebfingerJrd {
36
+ subject: string;
37
+ links: Array<{ rel: string; type?: string; href: string }>;
38
+ }
39
+
40
+ /** Minimal logging sink the webfinger router writes to. */
41
+ export interface WebfingerLogger {
42
+ error(message: string, detail?: unknown): void;
43
+ }
44
+
45
+ /** Adapters + config a {@link createWebfingerRouter} is built from. */
46
+ export interface WebfingerRouterConfig {
47
+ /** The app's federation domain (the only `acct:` domain this instance answers for). */
48
+ domain: string;
49
+ /** Whether federation is enabled (all routes 404 when off). */
50
+ federationEnabled: boolean;
51
+ /** Per-instance URL builders (the actor `self` link). */
52
+ urls: UrlBuilders;
53
+ /** Resolve a username to its Oxy user (null when unknown). */
54
+ resolveUser(username: string): Promise<WebfingerUser | null>;
55
+ /** The fediverse-sharing consent gate. */
56
+ consent: {
57
+ /** Sync read off an already-resolved user (the `'unavailable'` fallback). */
58
+ isSharingEnabledFromUser(user: WebfingerUser): boolean;
59
+ /** Fresh, uncached tri-state read by username. */
60
+ getSharingStateByUsername(username: string): Promise<WebfingerSharingState>;
61
+ };
62
+ /** JRD response cache (Mention: Redis). Reads/writes are best-effort. */
63
+ cache: {
64
+ get(username: string): Promise<WebfingerJrd | null>;
65
+ set(username: string, jrd: WebfingerJrd): void;
66
+ };
67
+ /** Diagnostics sink. */
68
+ logger: WebfingerLogger;
69
+ }
70
+
71
+ /** Build the WebFinger + host-meta discovery router for an app's domain. */
72
+ export function createWebfingerRouter(config: WebfingerRouterConfig): Router {
73
+ const router = Router();
74
+ const { domain } = config;
75
+ const webfingerTemplate = `https://${domain}/.well-known/webfinger?resource={uri}`;
76
+
77
+ router.get('/webfinger', async (req: Request, res: Response) => {
78
+ if (!config.federationEnabled) {
79
+ return res.status(404).json({ error: 'Federation is disabled' });
80
+ }
81
+
82
+ const resource = typeof req.query.resource === 'string' ? req.query.resource : undefined;
83
+ if (!resource || !resource.startsWith('acct:')) {
84
+ return res.status(400).json({ error: 'Resource must start with acct:' });
85
+ }
86
+
87
+ const acct = resource.replace('acct:', '');
88
+ const atIndex = acct.indexOf('@');
89
+ if (atIndex === -1) {
90
+ return res.status(400).json({ error: 'Invalid acct format' });
91
+ }
92
+
93
+ const username = normalizeActorUsername(acct.substring(0, atIndex));
94
+ const acctDomain = acct.substring(atIndex + 1).trim();
95
+
96
+ if (!isSameFederationHost(acctDomain, domain)) {
97
+ return res.status(404).json({ error: 'Unknown domain' });
98
+ }
99
+
100
+ try {
101
+ // The instance actor is NOT an Oxy user: it has no profile, no consent
102
+ // record, and `resolveUser` will never find it — so it must be answered
103
+ // here, ahead of both the resolve and the sharing-consent gate, exactly as
104
+ // the actor route answers ahead of them. Without this branch the server
105
+ // actor is served as an actor but is not WebFinger-resolvable, and every
106
+ // secure-mode instance then refuses our signed GETs: Mastodon's
107
+ // `FetchRemoteKeyService#find_actor` calls `FetchRemoteActorService`
108
+ // WITHOUT `only_key:`, which runs `check_webfinger!` unconditionally, so a
109
+ // 404 here raises `Webfinger::Error` and the signed fetch 401s.
110
+ //
111
+ // Deliberately NOT cached: the document is static (no I/O to amortize) and
112
+ // routing it through the app's JRD cache would let one stale or evicted
113
+ // entry make the server actor undiscoverable for a full TTL — which is the
114
+ // outage this branch exists to prevent.
115
+ if (username === INSTANCE_ACTOR_USERNAME) {
116
+ const instanceJrd: WebfingerJrd = {
117
+ subject: `acct:${INSTANCE_ACTOR_USERNAME}@${domain}`,
118
+ links: [
119
+ {
120
+ rel: 'self',
121
+ type: 'application/activity+json',
122
+ // The SAME builder call the actor route uses for the actor `id`.
123
+ // Mastodon compares `webfinger.self_link_href` against the actor
124
+ // uri and rejects a mismatch, so these must not be built twice.
125
+ href: config.urls.actor(INSTANCE_ACTOR_USERNAME),
126
+ },
127
+ // No `profile-page` rel: the server actor has no human-facing page
128
+ // (`/@instance` is not a profile), and the file's existing policy is
129
+ // that a dangling link is worse than an absent one.
130
+ ],
131
+ };
132
+ res.set('Content-Type', 'application/jrd+json; charset=utf-8');
133
+ res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
134
+ return res.json(instanceJrd);
135
+ }
136
+
137
+ // Check the JRD cache first.
138
+ const cached = await config.cache.get(username);
139
+ if (cached) {
140
+ res.set('Content-Type', 'application/jrd+json; charset=utf-8');
141
+ res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
142
+ return res.json(cached);
143
+ }
144
+
145
+ const user = await config.resolveUser(username);
146
+ if (!user) return res.status(404).json({ error: 'User not found' });
147
+
148
+ // Sharing OFF must be indistinguishable from a nonexistent user — same 404
149
+ // body, no separate error code. UNLIKE the other user-scoped surfaces,
150
+ // webfinger does a SECOND, uncached consent read here rather than reusing
151
+ // the already-resolved `user`: this response is ALSO cached for a full hour
152
+ // below, so a stale-DTO false positive would lock the actor (un)discoverable
153
+ // for up to an hour. An Oxy OUTAGE ('unavailable') on that fresh read falls
154
+ // back to the already-resolved `user` instead of 404ing, so a transient
155
+ // hiccup never makes a real account momentarily undiscoverable.
156
+ const sharingState = await config.consent.getSharingStateByUsername(username);
157
+ if (sharingState === 'disabled' || sharingState === 'unknown-user') {
158
+ return res.status(404).json({ error: 'User not found' });
159
+ }
160
+ if (sharingState === 'unavailable' && !config.consent.isSharingEnabledFromUser(user)) {
161
+ return res.status(404).json({ error: 'User not found' });
162
+ }
163
+
164
+ const response: WebfingerJrd = {
165
+ subject: `acct:${username}@${domain}`,
166
+ links: [
167
+ {
168
+ rel: 'self',
169
+ type: 'application/activity+json',
170
+ href: config.urls.actor(username),
171
+ },
172
+ {
173
+ rel: 'http://webfinger.net/rel/profile-page',
174
+ type: 'text/html',
175
+ href: `https://${domain}/@${username}`,
176
+ },
177
+ // NOTE: the `http://ostatus.org/schema/1.0/subscribe` (remote-follow) rel
178
+ // is intentionally omitted — there is no authorize-interaction endpoint
179
+ // to point it at, and a dangling template would be worse than its absence.
180
+ ],
181
+ };
182
+
183
+ config.cache.set(username, response);
184
+
185
+ res.set('Content-Type', 'application/jrd+json; charset=utf-8');
186
+ res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
187
+ return res.json(response);
188
+ } catch (err) {
189
+ config.logger.error('WebFinger error:', err);
190
+ return res.status(500).json({ error: 'Internal server error' });
191
+ }
192
+ });
193
+
194
+ router.get('/host-meta', (_req: Request, res: Response) => {
195
+ if (!config.federationEnabled) {
196
+ return res.status(404).json({ error: 'Federation is disabled' });
197
+ }
198
+ const xrd = `<?xml version="1.0" encoding="UTF-8"?>
199
+ <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
200
+ <Link rel="lrdd" type="application/jrd+json" template="${webfingerTemplate}"/>
201
+ </XRD>
202
+ `;
203
+ res.set('Content-Type', 'application/xrd+xml; charset=utf-8');
204
+ res.set('Cache-Control', HOST_META_CACHE_CONTROL);
205
+ return res.send(xrd);
206
+ });
207
+
208
+ router.get('/host-meta.json', (_req: Request, res: Response) => {
209
+ if (!config.federationEnabled) {
210
+ return res.status(404).json({ error: 'Federation is disabled' });
211
+ }
212
+ res.set('Content-Type', 'application/jrd+json; charset=utf-8');
213
+ res.set('Cache-Control', HOST_META_CACHE_CONTROL);
214
+ return res.json({
215
+ links: [
216
+ {
217
+ rel: 'lrdd',
218
+ type: 'application/jrd+json',
219
+ template: webfingerTemplate,
220
+ },
221
+ ],
222
+ });
223
+ });
224
+
225
+ return router;
226
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Domain-parameterized ActivityPub URL builders.
3
+ *
4
+ * Every Oxy app federates under its OWN domain (`@user@mention.earth`,
5
+ * `@user@homiio.com`, `@user@oxy.so`), so the actor/inbox/outbox/collection URLs
6
+ * an app mints must be scoped to that app's instance — never a module-level
7
+ * constant. {@link createUrlBuilders} is the factory each app instantiates once
8
+ * with its `FEDERATION_DOMAIN` (and, optionally, a distinct `ACTOR_DOMAIN`); the
9
+ * returned builders produce the exact URL shapes Mastodon and the rest of the
10
+ * fediverse expect, byte-for-byte identical to the strings the actor document
11
+ * advertises.
12
+ *
13
+ * `actor()` is scoped to `actorDomain` (the host in the actor `id` / `publicKey`
14
+ * owner) while every other builder is scoped to `domain`; in the common case both
15
+ * are the same host. The two are kept separate so a deployment that serves the
16
+ * actor namespace from a different host than the webfinger/inbox host can still
17
+ * advertise a self-consistent actor.
18
+ */
19
+
20
+ /** The per-instance ActivityPub URL builders, scoped to one app's domain. */
21
+ export interface UrlBuilders {
22
+ /** Actor `id` / `attributedTo` — `https://<actorDomain>/ap/users/<username>`. */
23
+ actor(username: string): string;
24
+ /** The actor's personal inbox. */
25
+ inbox(username: string): string;
26
+ /** The actor's outbox collection. */
27
+ outbox(username: string): string;
28
+ /** The actor's `featured` (pinned posts) collection. */
29
+ featured(username: string): string;
30
+ /** The actor's followers collection. */
31
+ followers(username: string): string;
32
+ /** The actor's following collection. */
33
+ following(username: string): string;
34
+ /** The instance-wide shared inbox. */
35
+ sharedInbox(): string;
36
+ }
37
+
38
+ /**
39
+ * Build the ActivityPub URL builders for an app instance.
40
+ *
41
+ * @param domain the app's federation domain (webfinger / inbox / collections host).
42
+ * @param actorDomain the host that owns the actor `id`; defaults to `domain`.
43
+ */
44
+ export function createUrlBuilders(domain: string, actorDomain: string = domain): UrlBuilders {
45
+ return {
46
+ actor: (username) => `https://${actorDomain}/ap/users/${username}`,
47
+ inbox: (username) => `https://${domain}/ap/users/${username}/inbox`,
48
+ outbox: (username) => `https://${domain}/ap/users/${username}/outbox`,
49
+ featured: (username) => `https://${domain}/ap/users/${username}/collections/featured`,
50
+ followers: (username) => `https://${domain}/ap/users/${username}/followers`,
51
+ following: (username) => `https://${domain}/ap/users/${username}/following`,
52
+ sharedInbox: () => `https://${domain}/ap/inbox`,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The reserved local-part of the instance-wide server actor (`Application`).
58
+ *
59
+ * This actor signs the engine's own outbound GETs; it is NOT an Oxy user, has no
60
+ * profile page and no fediverse-sharing consent record. Both the actor route and
61
+ * the WebFinger route must answer for it, and Mastodon compares the WebFinger
62
+ * `self` href against the actor `id` byte-for-byte before it will trust a signed
63
+ * fetch — so the two routers derive that URL from THIS constant through the same
64
+ * {@link UrlBuilders.actor} builder rather than each spelling the name themselves.
65
+ */
66
+ export const INSTANCE_ACTOR_USERNAME = 'instance';
67
+
68
+ /** Normalize a local actor username from a path segment or WebFinger acct local-part. */
69
+ export function normalizeActorUsername(username: string): string {
70
+ return username.trim().toLowerCase();
71
+ }