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