@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,216 @@
1
+ "use strict";
2
+ /**
3
+ * The single builder of a LOCAL user's ActivityPub actor document.
4
+ *
5
+ * Shared by the GET actor route (which serves it as a standalone JSON-LD
6
+ * document) and the outbound `Update` broadcast (which embeds it in an
7
+ * `Update` activity), so a follower's Mastodon renders the same actor whether it
8
+ * was fetched or pushed. Deliberately does NOT include the top-level `@context`:
9
+ * the GET route and the `Update` envelope each own their JSON-LD context, and an
10
+ * embedded actor object must not double-declare it.
11
+ *
12
+ * The exact bytes of this document are load-bearing — Mastodon negative-caches a
13
+ * malformed actor — so the field set, ordering, and the absolute-URL invariant on
14
+ * `icon`/`image` must stay byte-identical across every app that uses the engine.
15
+ *
16
+ * Media resolution is injected ({@link ActorMediaResolver}): the engine holds no
17
+ * knowledge of any app's media pipeline. The app resolves an avatar/banner
18
+ * reference (Oxy file id or URL) to a final absolute URL; the engine enforces the
19
+ * absolute-URL invariant and assembles the AP `Image` object.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = void 0;
23
+ exports.isApActorType = isApActorType;
24
+ exports.localActorTypeForAccountKind = localActorTypeForAccountKind;
25
+ exports.createLocalActorBuilder = createLocalActorBuilder;
26
+ const contracts_1 = require("@oxy.so/contracts");
27
+ /**
28
+ * The five actor types AS2 defines — the vocabulary for RECOGNIZING any actor,
29
+ * local or remote, as opposed to {@link LocalActorType} (the subset we emit).
30
+ *
31
+ * An inbound `Update` carrying a profile is dispatched on this: gating it on a
32
+ * hand-written subset is how a receiver silently stops applying profile edits
33
+ * from a whole class of account (a Lemmy community is a `Group`), with no error
34
+ * anywhere — the edit simply never lands.
35
+ */
36
+ exports.AP_ACTOR_TYPES = [
37
+ 'Application',
38
+ 'Group',
39
+ 'Organization',
40
+ 'Person',
41
+ 'Service',
42
+ ];
43
+ /** Whether an untrusted inbound `type` names an AS2 actor. */
44
+ function isApActorType(value) {
45
+ return typeof value === 'string' && exports.AP_ACTOR_TYPES.includes(value);
46
+ }
47
+ /**
48
+ * Oxy account kind → the AS2 actor type the fediverse is told about it.
49
+ *
50
+ * `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
51
+ * kind added to `@oxy.so/contracts` fails THIS build rather than silently
52
+ * inheriting `Person`, which is how every non-person account came to describe
53
+ * itself as an individual human in the first place.
54
+ *
55
+ * Per kind, and why:
56
+ *
57
+ * - **`personal` → `Person`.** The only kind that is a human login. Unchanged.
58
+ * - **`organization` → `Organization`.** AS2 has the exact word.
59
+ * - **`project` → `Organization`.** Least-wrong of the three available: a
60
+ * project is a collective endeavour, not an individual (`Person`) and not an
61
+ * automated one (`Service`).
62
+ * - **`bot` → `Service`.** Not merely AS2's word for automation — it is
63
+ * literally the value Mastodon writes when a local user ticks "this is an
64
+ * automated account" (`account.rb:224`), so it is the same claim its own
65
+ * users make about themselves. An Oxy `bot` announcing itself as a `Person`
66
+ * is false, and readers specifically want it labelled.
67
+ * - **`channel` → `Organization`.** A channel is a CONTENT identity that can
68
+ * never be logged into and takes no replies, so `Person` is false about it.
69
+ * `Group` would promise forwarding (above). `Service` was the tempting answer
70
+ * and is the WRONG one: it is the automation claim, and a channel is curated
71
+ * by people. Mastodon's `bot?` is exactly `%w(Application Service)`
72
+ * (`account.rb:90`), which paints an **"Automated"** badge with a robot icon
73
+ * (`badges.tsx:69`), drops the account from `SimilarProfilesSource`
74
+ * (`similar_profiles_source.rb:22-36`), and makes its notifications
75
+ * discardable by policy; Lemmy sets `bot_account = true`, hiding it from
76
+ * anyone who turned bots off. `Organization` costs NOTHING measurable: it is
77
+ * accepted by all four implementations' whitelists and compared in none of
78
+ * them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
79
+ * Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
80
+ *
81
+ * What this does NOT do: it does not stop a remote instance offering a reply box
82
+ * under a channel's post. NO actor type gates that in any of the four — Mastodon
83
+ * has no `canReply` at all (only `canQuote` and `canFeature`) — and AS2 has no
84
+ * interaction-policy field deployed software honours. A reply to a channel is
85
+ * still accepted by the sender's own instance and still dropped on arrival here.
86
+ * This map only stops asserting personhood about things that are not people.
87
+ */
88
+ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
89
+ personal: 'Person',
90
+ organization: 'Organization',
91
+ project: 'Organization',
92
+ bot: 'Service',
93
+ channel: 'Organization',
94
+ };
95
+ /**
96
+ * The AS2 actor type for an Oxy account kind, defaulting to `Person`.
97
+ *
98
+ * Takes `unknown` rather than `AccountKind` on purpose: the value arrives in an
99
+ * Oxy API response, so the static type is a claim about the wire that the wire
100
+ * can break. A deployment whose API knows a kind this package does not would
101
+ * index a miss and emit `type: undefined` — a MALFORMED actor, which Mastodon
102
+ * negative-caches for minutes to hours. `isAccountKind` (contracts' own narrowing,
103
+ * so it cannot drift from the vocabulary) sends an unrecognized or absent kind to
104
+ * `Person`: a valid actor, and the value every actor carried before this map
105
+ * existed.
106
+ */
107
+ function localActorTypeForAccountKind(kind) {
108
+ return (0, contracts_1.isAccountKind)(kind) ? exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
109
+ }
110
+ /** Map common image extensions to a MIME type for an actor image `mediaType`. */
111
+ const IMAGE_MEDIA_TYPE_BY_EXT = {
112
+ png: 'image/png',
113
+ jpg: 'image/jpeg',
114
+ jpeg: 'image/jpeg',
115
+ gif: 'image/gif',
116
+ webp: 'image/webp',
117
+ avif: 'image/avif',
118
+ };
119
+ /** True when `value` is an absolute `http(s)` URL. */
120
+ function isAbsoluteHttpUrl(value) {
121
+ try {
122
+ return /^https?:$/i.test(new URL(value).protocol);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ /**
129
+ * Build an ActivityPub `Image` object from an already-absolute URL, deriving
130
+ * `mediaType` from the URL extension when recognizable (a bare `Image` with a
131
+ * `url` is spec-valid, so an unknown extension simply omits `mediaType` rather
132
+ * than asserting a wrong one). Shared by the actor `icon` (avatar) and `image`
133
+ * (profile banner) builders.
134
+ */
135
+ function apImageObject(url) {
136
+ let extension;
137
+ try {
138
+ extension = new URL(url).pathname.split('.').pop()?.toLowerCase();
139
+ }
140
+ catch {
141
+ extension = url.split('?')[0]?.split('.').pop()?.toLowerCase();
142
+ }
143
+ const mediaType = extension ? IMAGE_MEDIA_TYPE_BY_EXT[extension] : undefined;
144
+ return mediaType ? { type: 'Image', url, mediaType } : { type: 'Image', url };
145
+ }
146
+ /**
147
+ * Build the actor `icon` (avatar) object, enforcing the absolute-URL invariant.
148
+ *
149
+ * ActivityPub consumers such as Mastodon validate that `icon.url` is an absolute
150
+ * URL and REJECT the entire actor document when it is not — so a non-absolute
151
+ * value makes the account undiscoverable. Returns undefined when there is no
152
+ * avatar or no absolute URL can be produced (Mastodon is fine with an
153
+ * avatar-less actor).
154
+ */
155
+ function buildActorIcon(config, avatar) {
156
+ if (!avatar)
157
+ return undefined;
158
+ const resolved = config.media.resolveAvatar(avatar);
159
+ if (!resolved || !isAbsoluteHttpUrl(resolved)) {
160
+ config.onWarn?.(`[Federation] Omitting actor icon — avatar did not resolve to an absolute URL (ref: ${avatar})`);
161
+ return undefined;
162
+ }
163
+ return apImageObject(resolved);
164
+ }
165
+ /**
166
+ * Build the actor `image` (profile banner/header) object, enforcing the same
167
+ * absolute-URL invariant as {@link buildActorIcon}. Mastodon renders the AP
168
+ * `image` property as the profile HEADER banner.
169
+ */
170
+ function buildActorImage(config, banner) {
171
+ if (!banner)
172
+ return undefined;
173
+ const resolved = config.media.resolveBanner(banner);
174
+ if (!resolved || !isAbsoluteHttpUrl(resolved)) {
175
+ config.onWarn?.(`[Federation] Omitting actor image — banner did not resolve to an absolute URL (ref: ${banner})`);
176
+ return undefined;
177
+ }
178
+ return apImageObject(resolved);
179
+ }
180
+ /**
181
+ * Build the per-instance local-actor builder. Bind it once with an app's domain +
182
+ * media resolver; call the returned function per user.
183
+ */
184
+ function createLocalActorBuilder(config) {
185
+ return (params) => {
186
+ const { username, displayName, kind, bio, avatar, profileHeaderImage, publicKey, createdAt } = params;
187
+ const actorObject = {
188
+ id: config.urls.actor(username),
189
+ type: localActorTypeForAccountKind(kind),
190
+ preferredUsername: username,
191
+ name: displayName,
192
+ summary: bio || '',
193
+ url: `https://${config.domain}/@${username}`,
194
+ inbox: config.urls.inbox(username),
195
+ outbox: config.urls.outbox(username),
196
+ featured: config.urls.featured(username),
197
+ followers: config.urls.followers(username),
198
+ following: config.urls.following(username),
199
+ endpoints: { sharedInbox: config.urls.sharedInbox() },
200
+ discoverable: true,
201
+ manuallyApprovesFollowers: false,
202
+ icon: buildActorIcon(config, avatar),
203
+ image: buildActorImage(config, profileHeaderImage),
204
+ publicKey: {
205
+ id: publicKey.keyId,
206
+ owner: config.urls.actor(username),
207
+ publicKeyPem: publicKey.publicKeyPem,
208
+ },
209
+ };
210
+ // `published` (account creation date) is advertised when the API provides it.
211
+ if (createdAt) {
212
+ actorObject.published = new Date(createdAt).toISOString();
213
+ }
214
+ return actorObject;
215
+ };
216
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AP_CONTEXT = void 0;
4
+ /**
5
+ * The shared JSON-LD `@context` every Oxy app emits on its ActivityPub actor and
6
+ * activity documents.
7
+ *
8
+ * These term declarations are LOAD-BEARING and must stay byte-identical across
9
+ * apps: a strict JSON-LD consumer DROPS any field whose term is not declared
10
+ * here, and Mastodon negative-caches a malformed actor for minutes/hours. The
11
+ * exact set below matches the proven Mention actor — `as:sensitive`, Mastodon's
12
+ * `toot:votersCount`, and the four interoperating quote-post terms
13
+ * (FEP-044f / FEP-e232 across Mastodon, Fedibird, Misskey and Pleroma/Akkoma).
14
+ */
15
+ exports.AP_CONTEXT = [
16
+ 'https://www.w3.org/ns/activitystreams',
17
+ 'https://w3id.org/security/v1',
18
+ // The AS2 core context above defines the `as:` prefix
19
+ // (`as` → `https://www.w3.org/ns/activitystreams#`), so this maps the Note's
20
+ // `sensitive` boolean to `as:sensitive` — the exact term Mastodon defines for
21
+ // it. Without the term declaration a JSON-LD consumer drops `sensitive`.
22
+ //
23
+ // `toot` is Mastodon's extension namespace; `votersCount` (the total unique
24
+ // voters on a poll `Question`) is `toot:votersCount` — the exact term Mastodon
25
+ // emits and reads. Without the declaration a JSON-LD consumer drops it. The
26
+ // `Question`/`oneOf`/`anyOf`/`endTime`/`closed` poll terms are all AS2 core, so
27
+ // they need no extra declaration here.
28
+ //
29
+ // Quote-post interop (FEP-044f / FEP-e232). A quote post carries the quoted
30
+ // object's canonical AP id under FOUR terms so the widest set of servers
31
+ // renders the inline quote: `quote` (FEP-044f, Mastodon 4.4+), `quoteUri`
32
+ // (Fedibird), `_misskey_quote` (Misskey) and `quoteUrl` (Pleroma/Akkoma). Each
33
+ // is typed `@id` (an IRI, not a literal); the `misskey`/`fedibird` namespaces
34
+ // and the AS2 `Link` type back the FEP-e232 `Link` quote tag. Without these
35
+ // declarations a strict JSON-LD consumer DROPS the quote fields.
36
+ {
37
+ sensitive: 'as:sensitive',
38
+ toot: 'http://joinmastodon.org/ns#',
39
+ votersCount: 'toot:votersCount',
40
+ misskey: 'https://misskey-hub.net/ns#',
41
+ fedibird: 'http://fedibird.com/ns#',
42
+ quote: { '@id': 'https://w3id.org/fep/044f#quote', '@type': '@id' },
43
+ quoteUri: { '@id': 'fedibird:quoteUri', '@type': '@id' },
44
+ quoteUrl: { '@id': 'as:quoteUrl', '@type': '@id' },
45
+ _misskey_quote: { '@id': 'misskey:_misskey_quote', '@type': '@id' },
46
+ Link: 'as:Link',
47
+ },
48
+ ];
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /**
3
+ * ActivityPub URI parsing + host canonicalisation + per-instance domain policy.
4
+ *
5
+ * `canonicalFederationHost` / `isSameFederationHost` are the one rule for "are
6
+ * these the same host", and every domain comparison the policy makes is built
7
+ * out of them. `extractActorUriFromActivityId` is pure and domain-agnostic. The
8
+ * blocked-domain check and the local-post-id extractor are DOMAIN-SCOPED — they
9
+ * depend on which hosts an app mints its own URIs under and which identity apex
10
+ * publishes its own users — so they come from a per-instance
11
+ * {@link createDomainPolicy} rather than a module-level constant.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.canonicalFederationHost = canonicalFederationHost;
15
+ exports.isSameFederationHost = isSameFederationHost;
16
+ exports.extractActorUriFromActivityId = extractActorUriFromActivityId;
17
+ exports.createDomainPolicy = createDomainPolicy;
18
+ /** Path segments that typically separate an actor path from a post ID in ActivityPub URIs. */
19
+ const POST_PATH_SEGMENTS = new Set(['statuses', 'posts', 'notes', 'objects', 'activities']);
20
+ /**
21
+ * THE FORM THIS ENGINE COMPARES HOSTS IN — trimmed, lowercased, one leading
22
+ * `www.` removed, and nothing else.
23
+ *
24
+ * It is exported because it is not an implementation detail: it decides whether
25
+ * two spellings of a host are the SAME host, and {@link createDomainPolicy} —
26
+ * the blocked-domain gate every inbound activity and every actor fetch passes
27
+ * through — is built out of this exact function. A consumer that keeps its own
28
+ * copy of the rule (a moderation blocklist, a transparency page, a content
29
+ * purge) is keeping a second opinion about which hosts are which, and the moment
30
+ * the two drift the consumer acts on domains the engine never refused. For a
31
+ * consumer whose action is irreversible that difference is deleted content.
32
+ *
33
+ * WHAT IT DELIBERATELY DOES NOT DO
34
+ *
35
+ * It does not strip a TRAILING DOT. `example.com.` is the fully-qualified
36
+ * spelling of `example.com` in DNS, but it is a different string here — and
37
+ * also on the wire, because `new URL('https://example.com./x').hostname`
38
+ * preserves the dot and that value is what the engine feeds in. So the two
39
+ * spellings do not match each other, in this function and in the engine
40
+ * alike. Widening that is a POLICY decision (it makes a blocklist match hosts
41
+ * it does not literally name) and belongs to whoever owns the policy, not to
42
+ * a string transform.
43
+ *
44
+ * It does not perform IDNA. The input is expected to be an ASCII host in the
45
+ * form the WHATWG URL parser produces — `new URL(...).hostname` has already
46
+ * applied ToASCII, so an internationalised host arrives as punycode
47
+ * (`xn--ber-goa.example`). A host spelled in unicode is lowercased but NOT
48
+ * converted, so it will not match its own punycode wire form. Callers that
49
+ * accept operator-typed hosts must convert them before comparing.
50
+ *
51
+ * @param host a bare host — no scheme, no port, no path.
52
+ */
53
+ function canonicalFederationHost(host) {
54
+ const value = host.trim().toLowerCase();
55
+ return value.startsWith('www.') ? value.slice(4) : value;
56
+ }
57
+ /**
58
+ * Whether two spellings name the same host under {@link canonicalFederationHost}.
59
+ *
60
+ * This is the question a caller actually has ("is the host on this activity the
61
+ * host we blocked?"), and it exists so that asking it does not require each
62
+ * caller to assemble its own comparison around the normaliser. Assembling one is
63
+ * where the mistakes happen, and they are quiet ones: a comparison that
64
+ * lowercases but forgets `www.`, or that allows `www.` on one side only and so
65
+ * answers differently depending on argument order, looks correct at every call
66
+ * site and is wrong for exactly the hosts an evasive instance will use.
67
+ *
68
+ * A blank string names no host, so it matches nothing — including another blank.
69
+ * That is the same answer {@link DomainPolicy.isBlockedDomain} gives it: a host
70
+ * that is not named is not in any set.
71
+ */
72
+ function isSameFederationHost(a, b) {
73
+ const canonicalA = canonicalFederationHost(a);
74
+ if (canonicalA.length === 0)
75
+ return false;
76
+ return canonicalA === canonicalFederationHost(b);
77
+ }
78
+ /**
79
+ * Given an ActivityPub activity/object ID (URL), extract the actor URI by
80
+ * trimming everything from the first recognised post-path segment onward.
81
+ *
82
+ * e.g. "https://mastodon.social/users/alice/statuses/12345"
83
+ * → "https://mastodon.social/users/alice"
84
+ *
85
+ * Returns null when the URL is malformed or no post-path segment is found.
86
+ */
87
+ function extractActorUriFromActivityId(activityId) {
88
+ try {
89
+ const url = new URL(activityId);
90
+ const segments = url.pathname.split('/').filter(Boolean);
91
+ const statusIdx = segments.findIndex((s) => POST_PATH_SEGMENTS.has(s));
92
+ if (statusIdx < 1)
93
+ return null;
94
+ return `${url.origin}/${segments.slice(0, statusIdx).join('/')}`;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Build the per-instance {@link DomainPolicy} from an app's domain configuration.
102
+ */
103
+ function createDomainPolicy(config) {
104
+ const localDomains = new Set([
105
+ canonicalFederationHost(config.domain),
106
+ canonicalFederationHost(config.actorDomain ?? config.domain),
107
+ ]);
108
+ const identityApex = config.identityApex ? canonicalFederationHost(config.identityApex) : undefined;
109
+ const blocked = new Set();
110
+ for (const d of config.blockedDomains ?? []) {
111
+ blocked.add(canonicalFederationHost(d));
112
+ }
113
+ return {
114
+ isBlockedDomain(domain) {
115
+ const d = canonicalFederationHost(domain);
116
+ return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
117
+ },
118
+ extractLocalPostId(objectUri) {
119
+ let parsed;
120
+ try {
121
+ parsed = new URL(objectUri);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ if (!localDomains.has(canonicalFederationHost(parsed.hostname)))
127
+ return null;
128
+ const match = parsed.pathname.match(/^\/ap\/users\/[^/]+\/posts\/([^/]+)\/?$/);
129
+ return match ? match[1] : null;
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * HTTP Signatures (draft-cavage-http-signatures-12) — the PURE sign/verify
4
+ * crypto that every Oxy app's ActivityPub federation shares.
5
+ *
6
+ * This is the highest-risk surface in the federation engine: the exact bytes of
7
+ * the signing string, the covered-header list and its order, the signature
8
+ * parameters, and the `X-Forwarded-Host` host reconstruction are what remote
9
+ * servers (Mastodon et al.) verify against. A one-character drift silently kills
10
+ * ALL federation, so this module is a byte-for-byte extraction of Mention's
11
+ * proven implementation — with the ONLY behavioural knobs made explicit:
12
+ *
13
+ * - **private-key custody is injected** ({@link HttpSignatureSigner}). The
14
+ * private key NEVER enters this package; the app supplies a `sign(keyId,
15
+ * signingString)` that (for Mention) calls oxy-api `POST /federation/sign`.
16
+ * - **`X-Forwarded-Host` trust is opt-in** ({@link VerifyHttpSignatureOptions.trustForwardedHost}).
17
+ * Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
18
+ * passes `true`; a directly-exposed origin leaves it `false`.
19
+ *
20
+ * Lives in the isomorphic `.` entry (no Express / Mongoose): it depends only on
21
+ * the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
22
+ * React-Native bundles — RN consumers import only the connector TYPES, which are
23
+ * erased at compile time.
24
+ */
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = void 0;
30
+ exports.signRequest = signRequest;
31
+ exports.verifyHttpSignature = verifyHttpSignature;
32
+ const node_crypto_1 = __importDefault(require("node:crypto"));
33
+ /** The signature algorithm parameter emitted in (and expected on) the `Signature` header. */
34
+ exports.HTTP_SIGNATURE_ALGORITHM = 'rsa-sha256';
35
+ /**
36
+ * The default content-type folded into the signing string for body-bearing
37
+ * requests. ActivityPub delivery signs `content-type` (some servers — e.g.
38
+ * Threads — require it), and the AP content type is always
39
+ * `application/activity+json`.
40
+ */
41
+ exports.DEFAULT_SIGNED_CONTENT_TYPE = 'application/activity+json';
42
+ /**
43
+ * Build the HTTP Signature header per draft-cavage-http-signatures-12 and sign it
44
+ * via the injected {@link HttpSignatureSigner} (the private key never enters this
45
+ * package).
46
+ *
47
+ * The spec-correct signing string is composed locally: `(request-target)`, host,
48
+ * date, and — for body-bearing requests — digest and content-type. The composed
49
+ * string is handed to `sign`, and the resulting signature is assembled into the
50
+ * `Signature:` header.
51
+ *
52
+ * Returns the headers to attach to the outbound request (Host, Date, optional
53
+ * Digest, and Signature). Content-Type is set by the deliverer's fetch.
54
+ */
55
+ async function signRequest(sign, keyId, method, url, body, options = {}) {
56
+ const contentType = options.contentType ?? exports.DEFAULT_SIGNED_CONTENT_TYPE;
57
+ const parsedUrl = new URL(url);
58
+ const date = new Date().toUTCString();
59
+ const headers = {
60
+ Host: parsedUrl.host,
61
+ Date: date,
62
+ };
63
+ const signedHeaderNames = ['(request-target)', 'host', 'date'];
64
+ const signingParts = [
65
+ `(request-target): ${method.toLowerCase()} ${parsedUrl.pathname}${parsedUrl.search}`,
66
+ `host: ${parsedUrl.host}`,
67
+ `date: ${date}`,
68
+ ];
69
+ if (body) {
70
+ const digest = node_crypto_1.default.createHash('sha256').update(body).digest('base64');
71
+ headers.Digest = `SHA-256=${digest}`;
72
+ signedHeaderNames.push('digest');
73
+ signingParts.push(`digest: SHA-256=${digest}`);
74
+ // Include content-type in signature (required by some servers like Threads)
75
+ signedHeaderNames.push('content-type');
76
+ signingParts.push(`content-type: ${contentType}`);
77
+ }
78
+ const signingString = signingParts.join('\n');
79
+ const signature = await sign(keyId, signingString);
80
+ headers.Signature = [
81
+ `keyId="${keyId}"`,
82
+ `algorithm="${exports.HTTP_SIGNATURE_ALGORITHM}"`,
83
+ `headers="${signedHeaderNames.join(' ')}"`,
84
+ `signature="${signature}"`,
85
+ ].join(',');
86
+ return headers;
87
+ }
88
+ /**
89
+ * Parse the Signature header from an incoming request.
90
+ */
91
+ function parseSignatureHeader(signatureHeader) {
92
+ const params = {};
93
+ const regex = /(\w+)="([^"]*)"/g;
94
+ let match = regex.exec(signatureHeader);
95
+ while (match !== null) {
96
+ params[match[1]] = match[2];
97
+ match = regex.exec(signatureHeader);
98
+ }
99
+ if (!params.keyId || !params.signature)
100
+ return null;
101
+ return {
102
+ keyId: params.keyId,
103
+ algorithm: params.algorithm || exports.HTTP_SIGNATURE_ALGORITHM,
104
+ headers: (params.headers || 'date').split(' '),
105
+ signature: params.signature,
106
+ };
107
+ }
108
+ /**
109
+ * Verify the HTTP signature on an incoming request.
110
+ * Returns the actor URI (key owner) if valid, null otherwise.
111
+ */
112
+ async function verifyHttpSignature(req, fetchPublicKey, options = {}) {
113
+ const signatureHeader = req.headers.signature;
114
+ if (!signatureHeader)
115
+ return { verified: false, reason: 'missing-signature' };
116
+ const parsed = parseSignatureHeader(signatureHeader);
117
+ if (!parsed)
118
+ return { verified: false, reason: 'invalid-signature-header' };
119
+ const keyData = await fetchPublicKey(parsed.keyId);
120
+ if (!keyData) {
121
+ options.onDebug?.(`Failed to fetch public key for keyId: ${parsed.keyId}`);
122
+ return { verified: false, reason: 'key-fetch-failed' };
123
+ }
124
+ const lowerHeaders = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k.toLowerCase(), v]));
125
+ // Enforce Date skew (+/- 10 minutes) if present
126
+ const dateHeader = lowerHeaders.date;
127
+ if (dateHeader) {
128
+ const dateVal = Array.isArray(dateHeader) ? dateHeader[0] : dateHeader;
129
+ const parsedDate = Date.parse(dateVal || '');
130
+ if (!Number.isNaN(parsedDate)) {
131
+ const skew = Math.abs(Date.now() - parsedDate);
132
+ if (skew > 10 * 60 * 1000) {
133
+ return { verified: false, reason: 'date-skew' };
134
+ }
135
+ }
136
+ }
137
+ // If Digest header is required in signature but missing/invalid, fail early
138
+ if (parsed.headers.includes('digest')) {
139
+ const digestHeader = lowerHeaders.digest;
140
+ const bodyString = typeof req.body === 'string' ? req.body : req.body ? JSON.stringify(req.body) : '';
141
+ if (!digestHeader) {
142
+ return { verified: false, reason: 'missing-digest' };
143
+ }
144
+ const expectedDigest = `SHA-256=${node_crypto_1.default.createHash('sha256').update(bodyString).digest('base64')}`;
145
+ const digestVal = Array.isArray(digestHeader) ? digestHeader[0] : digestHeader;
146
+ if (digestVal !== expectedDigest) {
147
+ return { verified: false, reason: 'digest-mismatch' };
148
+ }
149
+ }
150
+ const signingParts = parsed.headers.map((header) => {
151
+ const name = header.toLowerCase();
152
+ if (name === '(request-target)') {
153
+ return `(request-target): ${req.method.toLowerCase()} ${req.path}`;
154
+ }
155
+ // Reconstruct the `host` line from `x-forwarded-host` when the caller trusts
156
+ // it (an edge that rewrites the origin Host forwards the ORIGINAL signed host
157
+ // here; a proxy chain's FIRST comma token is the client-facing host). See
158
+ // VerifyHttpSignatureOptions.trustForwardedHost. Falls back to `host` when the
159
+ // header is absent (direct delivery), preserving direct-delivery behavior.
160
+ if (name === 'host' && options.trustForwardedHost) {
161
+ const forwarded = lowerHeaders['x-forwarded-host'];
162
+ const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
163
+ const firstToken = forwardedValue?.split(',')[0]?.trim();
164
+ if (firstToken) {
165
+ return `host: ${firstToken}`;
166
+ }
167
+ }
168
+ const value = lowerHeaders[name];
169
+ return `${name}: ${Array.isArray(value) ? value[0] : value}`;
170
+ });
171
+ const signingString = signingParts.join('\n');
172
+ const verifier = node_crypto_1.default.createVerify('sha256');
173
+ verifier.update(signingString);
174
+ verifier.end();
175
+ try {
176
+ const isValid = verifier.verify(keyData.publicKeyPem, parsed.signature, 'base64');
177
+ return {
178
+ verified: isValid,
179
+ actorUri: isValid ? keyData.actorUri : undefined,
180
+ reason: isValid ? undefined : 'verify-failed',
181
+ };
182
+ }
183
+ catch (err) {
184
+ options.onDebug?.('HTTP signature verification failed:', err);
185
+ return { verified: false, reason: err instanceof Error ? err.message : 'verify-exception' };
186
+ }
187
+ }