@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,84 @@
1
+ /**
2
+ * The network-neutral identity bridge — the DEFAULT implementation of the
3
+ * actor↔Oxy-user seam.
4
+ *
5
+ * The DATA is Oxy's: a remote actor is minted/updated as a `type:'federated'` Oxy
6
+ * user via `PUT /users/resolve`, and permanently-gone actors are archived
7
+ * (`POST /federation/actor-gone`) or hard-deleted (`POST /federation/actor-delete`)
8
+ * on the Oxy side. Those are service-scoped oxy-api calls (scope `federation:write`),
9
+ * so the transport is injected ({@link IdentityBridgeConfig.makeServiceRequest} —
10
+ * for Mention, `getServiceOxyClient().makeServiceRequest`). Private keys and
11
+ * canonical identity never enter this package.
12
+ *
13
+ * App-owned side effects are injected too: a post-resolve cache invalidation hook
14
+ * ({@link IdentityBridgeConfig.onUserResolved}) and the banner mirror
15
+ * ({@link IdentityBridgeConfig.mirrorBanner}, which uses the app's own media
16
+ * pipeline). The banner mirror is best-effort: a failure there must never discard
17
+ * an already-successful user resolution.
18
+ */
19
+ import type { NormalizedExternalActor } from '../index';
20
+ /** The HTTP methods the service-scoped oxy-api transport is invoked with. */
21
+ export type ServiceRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
22
+ /** The service-scoped request transport oxy-api calls go through (`{ data }`-unwrapped). */
23
+ export type ServiceRequest = <T>(method: ServiceRequestMethod, path: string, body?: unknown) => Promise<T>;
24
+ /** Minimal logging sink the identity bridge writes to. */
25
+ export interface IdentityBridgeLogger {
26
+ info(message: string, meta?: unknown): void;
27
+ warn(message: string, meta?: unknown): void;
28
+ }
29
+ /** Adapters the identity bridge is built from. */
30
+ export interface IdentityBridgeConfig {
31
+ /** Service-scoped oxy-api request transport (unwraps the API's `{ data }` envelope). */
32
+ makeServiceRequest: ServiceRequest;
33
+ /**
34
+ * Called with the resolved Oxy user id after a successful `PUT /users/resolve`
35
+ * (before the banner mirror). For Mention: evict the warm user-summary cache.
36
+ */
37
+ onUserResolved?: (oxyUserId: string) => Promise<void> | void;
38
+ /**
39
+ * Mirror the actor's remote banner into a durable app-owned asset. Best-effort:
40
+ * MUST NOT throw (it handles its own errors); a failure never drops the resolved
41
+ * user. Absent ⇒ banners are not mirrored.
42
+ */
43
+ mirrorBanner?: (bannerUrl: string, oxyUserId: string, actorUri: string) => Promise<void>;
44
+ /** Diagnostics sink. */
45
+ logger: IdentityBridgeLogger;
46
+ }
47
+ /**
48
+ * Outcome discriminant of {@link IdentityBridge.reportActorGone}. NEVER thrown —
49
+ * both callers (the live 410 tombstone and the one-shot prune sweep) are
50
+ * fail-soft, so the transient/retryable case is surfaced as a value (`'failed'`).
51
+ *
52
+ * - `archived` — Oxy archived a previously-active identity (removed from search).
53
+ * - `already` — the identity was already archived (idempotent no-op, still 200).
54
+ * - `skipped` — nothing to report, or a PERMANENT client error (400/403/404/409).
55
+ * - `failed` — a genuinely transient failure (5xx, 408/429, network). Retryable.
56
+ */
57
+ export type ReportActorGoneOutcome = 'archived' | 'already' | 'skipped' | 'failed';
58
+ /**
59
+ * Outcome discriminant of {@link IdentityBridge.deleteActorIdentity}. NEVER thrown.
60
+ *
61
+ * - `deleted` — oxy-api hard-deleted a live Oxy identity (+ follow edges/blocks).
62
+ * - `absent` — the identity was already gone (200, `deleted:false`). Oxy side clean.
63
+ * - `skipped` — nothing to delete, or a PERMANENT client error (400/403/409).
64
+ * - `failed` — a genuinely transient failure (5xx, 408/429, network). Retryable.
65
+ */
66
+ export type DeleteActorIdentityOutcome = 'deleted' | 'absent' | 'skipped' | 'failed';
67
+ /** The actor↔Oxy-user identity bridge. */
68
+ export interface IdentityBridge {
69
+ /**
70
+ * Resolve/mint the Oxy user a normalized external actor maps to, via
71
+ * `PUT /users/resolve` (service-scoped). Returns the resolved id, or `null` when
72
+ * Oxy is unreachable / returns no id (callers must then skip, never persisting an
73
+ * orphan). Mirrors the banner after resolution (best-effort).
74
+ */
75
+ resolveExternalUser(actor: NormalizedExternalActor, opts?: {
76
+ forceAvatarRefresh?: boolean;
77
+ }): Promise<string | null>;
78
+ /** Ask oxy-api to ARCHIVE the identity of a permanently-gone actor (reversible). */
79
+ reportActorGone(oxyUserId: string): Promise<ReportActorGoneOutcome>;
80
+ /** Ask oxy-api to HARD-DELETE the identity of a permanently-gone actor (irreversible). */
81
+ deleteActorIdentity(oxyUserId: string): Promise<DeleteActorIdentityOutcome>;
82
+ }
83
+ /** Build the network-neutral identity bridge from an app's adapters. */
84
+ export declare function createIdentityBridge(config: IdentityBridgeConfig): IdentityBridge;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Inbound ActivityPub dispatch + the follow-protocol handlers.
3
+ *
4
+ * The engine owns the DISPATCHER (validate the untrusted activity, switch on its
5
+ * type) and the FOLLOW-PROTOCOL verbs — Follow / Accept / Undo(Follow) / Reject —
6
+ * because those are identical across every Oxy app: they bridge a federated follow
7
+ * edge into the Oxy graph (via the identity adapter), record the AP-side follow
8
+ * row (via the store adapter), and send the Accept back (via the delivery
9
+ * service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
10
+ * non-follow Undo) is handed to the app-registered
11
+ * {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
12
+ * post/engagement handlers live. The consent gate + notification side effects are
13
+ * injected so the engine holds no app knowledge.
14
+ *
15
+ * Extracted behaviour-identically from Mention's former `InboxProcessingService`
16
+ * dispatcher + `handleIncomingFollow` / `handleUndo(Follow)` / `handleAccept` /
17
+ * `handleReject`.
18
+ */
19
+ /**
20
+ * Thrown when a federated follow is about to be bridged but the FOLLOWER actor
21
+ * has not yet resolved to an Oxy user (`oxyUserId` missing) — e.g. Oxy was
22
+ * unreachable when the actor was fetched. A federated follow MUST become a real
23
+ * Oxy edge, never a ghost, so the whole inbound activity is DEFERRED rather than
24
+ * bridged half-way:
25
+ *
26
+ * - in the BullMQ inbox worker, throwing fails the job, which retries with
27
+ * bounded exponential backoff; a later attempt (Oxy reachable) resolves the
28
+ * actor and bridges the follow. A permanently-unresolvable actor exhausts the
29
+ * attempts and the activity is dropped — never a ghost edge.
30
+ * - in the inline (no-Redis) fallback, it surfaces as a 500 from the inbox
31
+ * endpoint, so the remote re-delivers per ActivityPub.
32
+ */
33
+ export declare class ActorResolutionPendingError extends Error {
34
+ /** The remote actor URI whose Oxy resolution is still pending. */
35
+ readonly actorUri: string;
36
+ constructor(actorUri: string, context?: string);
37
+ }
38
+ /** Minimal logging sink the inbound dispatcher writes to. */
39
+ export interface InboundDispatcherLogger {
40
+ debug(message: string): void;
41
+ info(message: string): void;
42
+ warn(message: string, detail?: unknown): void;
43
+ }
44
+ /**
45
+ * The verdict of validating an untrusted inbound activity: its primary type, or a
46
+ * compact failure summary. The app owns the validation (its zod schemas); the
47
+ * engine owns the drop-with-warn behaviour so every app logs identically.
48
+ */
49
+ export type InboundActivityValidation = {
50
+ ok: true;
51
+ type: string;
52
+ } | {
53
+ ok: false;
54
+ summary: string;
55
+ };
56
+ /** The Oxy-user fields the engine reads off a resolved local user (Follow target). */
57
+ export interface InboundLocalUser {
58
+ _id?: string | null;
59
+ id?: string | null;
60
+ }
61
+ /** The actor↔Oxy-user identity bridge the follow verbs use. */
62
+ export interface InboundIdentity {
63
+ /** Resolve a local username to its Oxy user (the Follow target). Null when unknown. */
64
+ resolveUserByUsername(username: string): Promise<InboundLocalUser | null>;
65
+ /** Create the Oxy follow edge (`POST /federation/follow`). Throws on transport failure (retry). */
66
+ bridgeFollow(followerOxyUserId: string, localUserId: string): Promise<void>;
67
+ /** Remove the Oxy follow edge. Throws on transport failure (retry). */
68
+ bridgeUnfollow(followerOxyUserId: string, localUserId: string): Promise<void>;
69
+ }
70
+ /** The fediverse-sharing consent gate for the inbound Follow. */
71
+ export interface InboundConsent {
72
+ /** Sync read off an already-resolved user object (absent flag ⇒ enabled). */
73
+ isSharingEnabledFromUser(user: InboundLocalUser): boolean;
74
+ }
75
+ /** The actor resolver subset the inbound follow uses (resolve + require an Oxy id). */
76
+ export interface InboundActorResolver {
77
+ /** Resolve/mint the follower actor and its Oxy user (`getOrFetchActor`). */
78
+ getOrFetchActor(actorUri: string): Promise<{
79
+ oxyUserId?: string | null;
80
+ } | null>;
81
+ }
82
+ /** Bring-your-own-store: the AP follow records + actor cache reads the follow verbs need. */
83
+ export interface InboundFollowStore {
84
+ /** `handleIncomingFollow`: upsert the accepted inbound follow row. */
85
+ upsertInboundAccepted(localUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
86
+ /** `handleUndo(Follow)`: the inbound follow row (scoped by localUserId when known). */
87
+ findInboundFollow(remoteActorUri: string, localUserId?: string): Promise<{
88
+ _id: unknown;
89
+ localUserId: string;
90
+ } | null>;
91
+ /** `handleUndo(Follow)`: delete a follow row by id. */
92
+ deleteFollowById(id: unknown): Promise<void>;
93
+ /** `handleUndo(Follow)`: the follower actor's cached Oxy user id (for `bridgeUnfollow`). */
94
+ findActorOxyUserId(uri: string): Promise<string | null | undefined>;
95
+ /** `handleAccept`: mark the matching outbound-pending follow accepted BY its activity id. Returns whether a row changed. */
96
+ markOutboundAcceptedByActivityId(remoteActorUri: string, activityId: string): Promise<boolean>;
97
+ /** `handleAccept`: mark ANY outbound-pending follow for this actor accepted. Returns whether a row changed. */
98
+ markOutboundAcceptedAnyPending(remoteActorUri: string): Promise<boolean>;
99
+ /** `handleReject`: mark the matching outbound-pending follow rejected. */
100
+ markOutboundRejected(remoteActorUri: string, activityId?: string): Promise<void>;
101
+ }
102
+ /** The delivery subset the inbound Follow uses (send the Accept back). */
103
+ export interface InboundDelivery {
104
+ sendAccept(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
105
+ }
106
+ /** Adapters + hooks an {@link InboundDispatcher} is built from. */
107
+ export interface InboundDispatcherConfig {
108
+ /**
109
+ * The app's per-instance domain policy (`DomainPolicy.isBlockedDomain`) — our own
110
+ * ActivityPub domains, the Oxy identity apex, and every explicitly suspended
111
+ * instance. Applied to the host of the VERIFIED origin actor before an inbound
112
+ * activity is dispatched, so a suspended instance can create nothing here.
113
+ *
114
+ * REQUIRED, deliberately: an app that forgets to wire it would silently federate
115
+ * with every instance it has ever cached, which is exactly the failure this gate
116
+ * exists to prevent. A missing policy must be a compile error, not a quiet hole.
117
+ */
118
+ isBlockedDomain(host: string): boolean;
119
+ /** Validate + extract the primary type of an untrusted inbound activity (app's zod schemas). */
120
+ validateActivity(activity: Record<string, unknown>): InboundActivityValidation;
121
+ /** The actor↔Oxy-user identity bridge. */
122
+ identity: InboundIdentity;
123
+ /** The fediverse-sharing consent gate. */
124
+ consent: InboundConsent;
125
+ /** The actor resolver (resolve the follower actor). */
126
+ actorResolver: InboundActorResolver;
127
+ /** The AP follow-record store. */
128
+ follows: InboundFollowStore;
129
+ /** The delivery service (send the Accept). */
130
+ delivery: InboundDelivery;
131
+ /**
132
+ * Best-effort: notify the local user of a newly-accepted inbound follow.
133
+ * NEVER throws (it handles its own errors); a failure must not fail (and thus
134
+ * retry) the inbox activity. Absent ⇒ no notification.
135
+ */
136
+ onInboundFollowAccepted?(localUserId: string, followerOxyUserId: string, actorUri: string): Promise<void>;
137
+ /**
138
+ * Best-effort: backfill the newly-followed remote actor's recent posts after an
139
+ * outbound Follow was Accepted. NEVER throws. Absent ⇒ no backfill.
140
+ */
141
+ onOutboundFollowAccepted?(actorUri: string): Promise<void>;
142
+ /**
143
+ * Handle a CONTENT activity the engine does not own — Create / Announce / Like /
144
+ * Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
145
+ */
146
+ onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
147
+ /** Diagnostics sink. */
148
+ logger: InboundDispatcherLogger;
149
+ }
150
+ /** The inbound-activity dispatcher. */
151
+ export interface InboundDispatcher {
152
+ /** Process one already-actor-verified inbound activity. */
153
+ processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
154
+ }
155
+ /** Build the inbound-activity dispatcher from an app's adapters + content handlers. */
156
+ export declare function createInboundDispatcher(config: InboundDispatcherConfig): InboundDispatcher;
@@ -0,0 +1,51 @@
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
+ export { createSignedFetch, type SignedFetch, type CreateSignedFetchConfig, type SingleHopFetch, type SingleHopFetchInit, type SignedFetchLogger, } from './signedFetch';
20
+ /**
21
+ * The actor↔Oxy-user identity bridge — the default implementation of the
22
+ * `PUT /users/resolve` + actor-gone archive/delete seam over an injected
23
+ * service-request transport.
24
+ */
25
+ export { createIdentityBridge, type IdentityBridge, type IdentityBridgeConfig, type IdentityBridgeLogger, type ServiceRequest, type ServiceRequestMethod, type ReportActorGoneOutcome, type DeleteActorIdentityOutcome, } from './identityBridge';
26
+ /**
27
+ * Remote-actor resolution/caching/refresh (webfinger, signed actor fetch,
28
+ * 410-Gone tombstone) over a bring-your-own-store adapter, the identity bridge,
29
+ * and injected transports + text normalization.
30
+ */
31
+ export { createActorResolver, ActorResolver, type ActorResolverConfig, type ActorResolverIdentity, type ActorResolverLogger, type ActorTextAdapter, type FederatedActorStore, type FederatedActorRecordBase, type FederatedActorUpsert, type FederatedActorField, type WebFingerFetch, type WebFingerJrd, } from './actorResolver';
32
+ /**
33
+ * Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
34
+ * Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
35
+ * custody, an SSRF-safe delivery transport, and bring-your-own-store adapters.
36
+ */
37
+ export { createDeliveryService, type DeliveryService, type DeliveryServiceConfig, type DeliveryLogger, type DeliveryKeys, type DeliverSingleHop, type DeliverSingleHopInit, type DeliverSingleHopResult, type DeliveryResponseStream, type DeliveryTransport, type DeliveryQueueJob, type DeliveryFallbackQueue, type DeliveryActorFields, type DeliveryActorStore, type DeliveryFollowStore, type DeliveryActorRefresh, type DeliveryConsent, type DeliveryActorProfile, type DeliveryIdentity, type DeliveryProfile, type SafeUrlVerdict, } from './delivery';
38
+ /**
39
+ * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
40
+ * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
41
+ * identity + store adapters, and the `onContentActivity` seam every content verb
42
+ * (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
43
+ */
44
+ export { createInboundDispatcher, ActorResolutionPendingError, type InboundDispatcher, type InboundDispatcherConfig, type InboundDispatcherLogger, type InboundActivityValidation, type InboundLocalUser, type InboundIdentity, type InboundConsent, type InboundActorResolver, type InboundFollowStore, type InboundDelivery, } from './inboundDispatch';
45
+ /** The WebFinger + host-meta discovery router (domain-parameterized, consent-gated). */
46
+ export { createWebfingerRouter, type WebfingerRouterConfig, type WebfingerSharingState, type WebfingerUser, type WebfingerJrd, type WebfingerLogger, } from './webfingerRouter';
47
+ /**
48
+ * The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
49
+ * `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
50
+ */
51
+ export { createActorRouter, type ActorRouterConfig, type ActorRouteUser, type ActorSharingState, type ActorRouterLogger, type FollowPage, } from './actorRouter';
@@ -0,0 +1,74 @@
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
+ import { type HttpSignatureSigner } from '../httpSignature';
21
+ /** Per-request options handed to the injected single-hop transport. */
22
+ export interface SingleHopFetchInit {
23
+ /** The EXACT request headers to send (the factory assembles these). */
24
+ headers: Record<string, string>;
25
+ /** Aborts the in-flight request when the signal fires. */
26
+ signal: AbortSignal;
27
+ /** Time-to-first-byte deadline in milliseconds. */
28
+ headersTimeoutMs?: number;
29
+ }
30
+ /**
31
+ * An SSRF-safe single-hop fetch: it validates + IP-pins the URL and returns the
32
+ * response WITHOUT following redirects (a 3xx is returned as-is so the caller can
33
+ * re-sign the next hop). Mention adapts its `@oxy.so/core/server`-backed
34
+ * `fetchUpstreamSingleHop` into this shape.
35
+ */
36
+ export type SingleHopFetch = (url: string, init: SingleHopFetchInit) => Promise<Response>;
37
+ /** Non-fatal diagnostics sink for signed fetches. */
38
+ export interface SignedFetchLogger {
39
+ info(message: string): void;
40
+ warn(message: string): void;
41
+ }
42
+ /** Adapters + config a {@link SignedFetch} is built from. */
43
+ export interface CreateSignedFetchConfig {
44
+ /** RSA-SHA256 signer — private-key custody stays behind this (Mention: oxy-api). */
45
+ sign: HttpSignatureSigner;
46
+ /** Resolve the instance actor's `keyId` used to sign outbound GETs. */
47
+ getInstanceKeyId: () => Promise<string>;
48
+ /** SSRF-safe single-hop transport (does NOT follow redirects). */
49
+ fetchSingleHop: SingleHopFetch;
50
+ /** User-Agent presented to remote servers. */
51
+ userAgent: string;
52
+ /** Optional diagnostics sink (5xx unsigned retry, 401/403 rejection). */
53
+ logger?: SignedFetchLogger;
54
+ }
55
+ /** A signed ActivityPub GET, returning the standard WHATWG {@link Response}. */
56
+ export type SignedFetch = (url: string, accept: string, init?: RequestInit) => Promise<Response>;
57
+ /**
58
+ * Build a `signedFetch(url, accept, init?)`:
59
+ *
60
+ * Signs a GET request using the instance actor key (via the injected signer) and
61
+ * performs it under the SSRF-safe contract (the injected single-hop transport
62
+ * validates the URL AND pins the TCP connection to the validated IP).
63
+ *
64
+ * Redirects are followed manually (bounded by {@link SIGNED_FETCH_MAX_REDIRECTS}),
65
+ * re-validating AND re-signing each hop — an HTTP signature is bound to the
66
+ * `(request-target)`/`host` of a specific URL. When the caller passes
67
+ * `init.redirect === 'manual'`, the redirect `Response` is returned directly so
68
+ * the caller can apply its own stricter redirect policy.
69
+ *
70
+ * Signed for servers that enforce authorized fetch (e.g. Threads). On a 5xx the
71
+ * request is retried unsigned (same SSRF-safe path) as a fallback for public
72
+ * resources.
73
+ */
74
+ export declare function createSignedFetch(config: CreateSignedFetchConfig): SignedFetch;
@@ -0,0 +1,62 @@
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
+ import { Router } from 'express';
15
+ import type { UrlBuilders } from '../urls';
16
+ /** The tri-state consent read for a username with no already-resolved user object. */
17
+ export type WebfingerSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
18
+ /** The resolved-user fields the webfinger consent fallback reads. */
19
+ export interface WebfingerUser {
20
+ _id?: string | null;
21
+ id?: string | null;
22
+ }
23
+ /** A cached WebFinger JRD document (the `subject` + `links` shape served below). */
24
+ export interface WebfingerJrd {
25
+ subject: string;
26
+ links: Array<{
27
+ rel: string;
28
+ type?: string;
29
+ href: string;
30
+ }>;
31
+ }
32
+ /** Minimal logging sink the webfinger router writes to. */
33
+ export interface WebfingerLogger {
34
+ error(message: string, detail?: unknown): void;
35
+ }
36
+ /** Adapters + config a {@link createWebfingerRouter} is built from. */
37
+ export interface WebfingerRouterConfig {
38
+ /** The app's federation domain (the only `acct:` domain this instance answers for). */
39
+ domain: string;
40
+ /** Whether federation is enabled (all routes 404 when off). */
41
+ federationEnabled: boolean;
42
+ /** Per-instance URL builders (the actor `self` link). */
43
+ urls: UrlBuilders;
44
+ /** Resolve a username to its Oxy user (null when unknown). */
45
+ resolveUser(username: string): Promise<WebfingerUser | null>;
46
+ /** The fediverse-sharing consent gate. */
47
+ consent: {
48
+ /** Sync read off an already-resolved user (the `'unavailable'` fallback). */
49
+ isSharingEnabledFromUser(user: WebfingerUser): boolean;
50
+ /** Fresh, uncached tri-state read by username. */
51
+ getSharingStateByUsername(username: string): Promise<WebfingerSharingState>;
52
+ };
53
+ /** JRD response cache (Mention: Redis). Reads/writes are best-effort. */
54
+ cache: {
55
+ get(username: string): Promise<WebfingerJrd | null>;
56
+ set(username: string, jrd: WebfingerJrd): void;
57
+ };
58
+ /** Diagnostics sink. */
59
+ logger: WebfingerLogger;
60
+ }
61
+ /** Build the WebFinger + host-meta discovery router for an app's domain. */
62
+ export declare function createWebfingerRouter(config: WebfingerRouterConfig): Router;
@@ -0,0 +1,55 @@
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
+ /** The per-instance ActivityPub URL builders, scoped to one app's domain. */
20
+ export interface UrlBuilders {
21
+ /** Actor `id` / `attributedTo` — `https://<actorDomain>/ap/users/<username>`. */
22
+ actor(username: string): string;
23
+ /** The actor's personal inbox. */
24
+ inbox(username: string): string;
25
+ /** The actor's outbox collection. */
26
+ outbox(username: string): string;
27
+ /** The actor's `featured` (pinned posts) collection. */
28
+ featured(username: string): string;
29
+ /** The actor's followers collection. */
30
+ followers(username: string): string;
31
+ /** The actor's following collection. */
32
+ following(username: string): string;
33
+ /** The instance-wide shared inbox. */
34
+ sharedInbox(): string;
35
+ }
36
+ /**
37
+ * Build the ActivityPub URL builders for an app instance.
38
+ *
39
+ * @param domain the app's federation domain (webfinger / inbox / collections host).
40
+ * @param actorDomain the host that owns the actor `id`; defaults to `domain`.
41
+ */
42
+ export declare function createUrlBuilders(domain: string, actorDomain?: string): UrlBuilders;
43
+ /**
44
+ * The reserved local-part of the instance-wide server actor (`Application`).
45
+ *
46
+ * This actor signs the engine's own outbound GETs; it is NOT an Oxy user, has no
47
+ * profile page and no fediverse-sharing consent record. Both the actor route and
48
+ * the WebFinger route must answer for it, and Mastodon compares the WebFinger
49
+ * `self` href against the actor `id` byte-for-byte before it will trust a signed
50
+ * fetch — so the two routers derive that URL from THIS constant through the same
51
+ * {@link UrlBuilders.actor} builder rather than each spelling the name themselves.
52
+ */
53
+ export declare const INSTANCE_ACTOR_USERNAME = "instance";
54
+ /** Normalize a local actor username from a path segment or WebFinger acct local-part. */
55
+ export declare function normalizeActorUsername(username: string): string;
package/package.json ADDED
@@ -0,0 +1,119 @@
1
+ {
2
+ "name": "@oxy.so/federation",
3
+ "version": "1.0.0",
4
+ "description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
5
+ "main": "dist/cjs/index.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/types/index.d.ts",
8
+ "typesVersions": {
9
+ "*": {
10
+ "node": [
11
+ "dist/types/node/index.d.ts"
12
+ ]
13
+ }
14
+ },
15
+ "source": "src/index.ts",
16
+ "sideEffects": false,
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "import": {
23
+ "types": "./dist/types/index.d.ts",
24
+ "default": "./dist/esm/index.js"
25
+ },
26
+ "require": {
27
+ "types": "./dist/types/index.d.ts",
28
+ "default": "./dist/cjs/index.js"
29
+ },
30
+ "default": "./dist/esm/index.js"
31
+ },
32
+ "./node": {
33
+ "import": {
34
+ "types": "./dist/types/node/index.d.ts",
35
+ "default": "./dist/esm/node/index.js"
36
+ },
37
+ "require": {
38
+ "types": "./dist/types/node/index.d.ts",
39
+ "default": "./dist/cjs/node/index.js"
40
+ },
41
+ "default": "./dist/esm/node/index.js"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "files": [
46
+ "NOTICE",
47
+ "dist",
48
+ "src"
49
+ ],
50
+ "keywords": [
51
+ "oxyhq",
52
+ "federation",
53
+ "activitypub",
54
+ "webfinger",
55
+ "fediverse",
56
+ "connector"
57
+ ],
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/OxyHQ/OxyHQServices",
61
+ "directory": "packages/federation"
62
+ },
63
+ "author": "OxyHQ",
64
+ "license": "Apache-2.0",
65
+ "homepage": "https://oxy.so",
66
+ "engines": {
67
+ "node": ">=18.0.0"
68
+ },
69
+ "scripts": {
70
+ "build": "bun run --filter @oxy.so/contracts build && bun run --filter @oxy.so/protocol build && bun run --filter @oxy.so/core build && bun run build:cjs && bun run build:esm && bun run build:types",
71
+ "build:cjs": "tsc -p tsconfig.cjs.json",
72
+ "build:esm": "tsc -p tsconfig.esm.json && node scripts/fix-esm-imports.mjs",
73
+ "build:types": "tsc -p tsconfig.types.json",
74
+ "clean": "rm -rf dist",
75
+ "typescript": "tsc --noEmit",
76
+ "test": "jest --passWithNoTests",
77
+ "lint": "biome lint --error-on-warnings ./src",
78
+ "prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run clean && bun run build",
79
+ "release": "rm -rf dist && bun run build && release-it"
80
+ },
81
+ "release-it": {
82
+ "git": {
83
+ "tagName": "@oxy.so/federation@${version}",
84
+ "tagAnnotation": "Release @oxy.so/federation@${version}",
85
+ "commitMessage": "chore(federation): release @oxy.so/federation@${version}"
86
+ },
87
+ "github": {
88
+ "release": true,
89
+ "releaseName": "@oxy.so/federation@${version}"
90
+ },
91
+ "npm": {
92
+ "publish": true
93
+ }
94
+ },
95
+ "dependencies": {
96
+ "@oxy.so/contracts": "^1.0.0",
97
+ "@oxy.so/core": "^1.0.0"
98
+ },
99
+ "peerDependencies": {
100
+ "express": "^4.18.0 || ^5.0.0"
101
+ },
102
+ "peerDependenciesMeta": {
103
+ "express": {
104
+ "optional": true
105
+ }
106
+ },
107
+ "devDependencies": {
108
+ "@biomejs/biome": "^1.9.4",
109
+ "@types/express": "^4.17.25",
110
+ "@types/jest": "^30.0.0",
111
+ "@types/node": "^22.20.1",
112
+ "express": "^4.22.2",
113
+ "jest": "^30.5.1",
114
+ "release-it": "^19.0.6",
115
+ "supertest": "^7.0.0",
116
+ "ts-jest": "^29.4.12",
117
+ "typescript": "^5.9.2"
118
+ }
119
+ }