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