@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,313 @@
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
+
21
+ import { type AccountKind, isAccountKind } from '@oxy.so/contracts';
22
+ import type { UrlBuilders } from './urls';
23
+
24
+ /**
25
+ * The five actor types AS2 defines — the vocabulary for RECOGNIZING any actor,
26
+ * local or remote, as opposed to {@link LocalActorType} (the subset we emit).
27
+ *
28
+ * An inbound `Update` carrying a profile is dispatched on this: gating it on a
29
+ * hand-written subset is how a receiver silently stops applying profile edits
30
+ * from a whole class of account (a Lemmy community is a `Group`), with no error
31
+ * anywhere — the edit simply never lands.
32
+ */
33
+ export const AP_ACTOR_TYPES = [
34
+ 'Application',
35
+ 'Group',
36
+ 'Organization',
37
+ 'Person',
38
+ 'Service',
39
+ ] as const;
40
+
41
+ /** Any AS2 actor type. */
42
+ export type ApActorType = (typeof AP_ACTOR_TYPES)[number];
43
+
44
+ /** Whether an untrusted inbound `type` names an AS2 actor. */
45
+ export function isApActorType(value: unknown): value is ApActorType {
46
+ return typeof value === 'string' && (AP_ACTOR_TYPES as readonly string[]).includes(value);
47
+ }
48
+
49
+ /**
50
+ * The AS2 actor types this engine will announce for a LOCAL user actor.
51
+ *
52
+ * Narrower than AS2's five actor types, and narrow ON PURPOSE — the union names
53
+ * what the builder can emit, so the two absences are documented decisions rather
54
+ * than oversights. Both were checked against real receiving implementations
55
+ * (mastodon `a3649295`, lemmy `4ce92433`, misskey `b95e4841`, peertube
56
+ * `fe0da961`), not against the spec:
57
+ *
58
+ * - **`Application`** is the INSTANCE actor's type (see the actor router's
59
+ * `/ap/users/instance` branch), reserved by convention for the software
60
+ * itself. No account is the software.
61
+ * - **`Group`** is refused because in the deployed fediverse it is not read as
62
+ * "a collective of actors" but as a FORWARDING actor, and the two failure
63
+ * modes are concrete. Lemmy reclassifies a remote `Group` as a COMMUNITY:
64
+ * it is followable and the Follow is Accepted, so it looks like it worked,
65
+ * and then it never shows a single post — we emit no `Announce`, and our
66
+ * Notes do not resolve to a community. PeerTube is worse and louder: it
67
+ * REJECTS a `Group` actor outright unless it carries `attributedTo` naming a
68
+ * `Person`. An Oxy account authors its own posts and forwards nothing, so
69
+ * `Group` would advertise a protocol this engine does not implement.
70
+ */
71
+ export type LocalActorType = Extract<ApActorType, 'Person' | 'Organization' | 'Service'>;
72
+
73
+ /**
74
+ * Oxy account kind → the AS2 actor type the fediverse is told about it.
75
+ *
76
+ * `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
77
+ * kind added to `@oxy.so/contracts` fails THIS build rather than silently
78
+ * inheriting `Person`, which is how every non-person account came to describe
79
+ * itself as an individual human in the first place.
80
+ *
81
+ * Per kind, and why:
82
+ *
83
+ * - **`personal` → `Person`.** The only kind that is a human login. Unchanged.
84
+ * - **`organization` → `Organization`.** AS2 has the exact word.
85
+ * - **`project` → `Organization`.** Least-wrong of the three available: a
86
+ * project is a collective endeavour, not an individual (`Person`) and not an
87
+ * automated one (`Service`).
88
+ * - **`bot` → `Service`.** Not merely AS2's word for automation — it is
89
+ * literally the value Mastodon writes when a local user ticks "this is an
90
+ * automated account" (`account.rb:224`), so it is the same claim its own
91
+ * users make about themselves. An Oxy `bot` announcing itself as a `Person`
92
+ * is false, and readers specifically want it labelled.
93
+ * - **`channel` → `Organization`.** A channel is a CONTENT identity that can
94
+ * never be logged into and takes no replies, so `Person` is false about it.
95
+ * `Group` would promise forwarding (above). `Service` was the tempting answer
96
+ * and is the WRONG one: it is the automation claim, and a channel is curated
97
+ * by people. Mastodon's `bot?` is exactly `%w(Application Service)`
98
+ * (`account.rb:90`), which paints an **"Automated"** badge with a robot icon
99
+ * (`badges.tsx:69`), drops the account from `SimilarProfilesSource`
100
+ * (`similar_profiles_source.rb:22-36`), and makes its notifications
101
+ * discardable by policy; Lemmy sets `bot_account = true`, hiding it from
102
+ * anyone who turned bots off. `Organization` costs NOTHING measurable: it is
103
+ * accepted by all four implementations' whitelists and compared in none of
104
+ * them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
105
+ * Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
106
+ *
107
+ * What this does NOT do: it does not stop a remote instance offering a reply box
108
+ * under a channel's post. NO actor type gates that in any of the four — Mastodon
109
+ * has no `canReply` at all (only `canQuote` and `canFeature`) — and AS2 has no
110
+ * interaction-policy field deployed software honours. A reply to a channel is
111
+ * still accepted by the sender's own instance and still dropped on arrival here.
112
+ * This map only stops asserting personhood about things that are not people.
113
+ */
114
+ export const LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
115
+ personal: 'Person',
116
+ organization: 'Organization',
117
+ project: 'Organization',
118
+ bot: 'Service',
119
+ channel: 'Organization',
120
+ } as const satisfies Record<AccountKind, LocalActorType>;
121
+
122
+ /**
123
+ * The AS2 actor type for an Oxy account kind, defaulting to `Person`.
124
+ *
125
+ * Takes `unknown` rather than `AccountKind` on purpose: the value arrives in an
126
+ * Oxy API response, so the static type is a claim about the wire that the wire
127
+ * can break. A deployment whose API knows a kind this package does not would
128
+ * index a miss and emit `type: undefined` — a MALFORMED actor, which Mastodon
129
+ * negative-caches for minutes to hours. `isAccountKind` (contracts' own narrowing,
130
+ * so it cannot drift from the vocabulary) sends an unrecognized or absent kind to
131
+ * `Person`: a valid actor, and the value every actor carried before this map
132
+ * existed.
133
+ */
134
+ export function localActorTypeForAccountKind(kind: unknown): LocalActorType {
135
+ return isAccountKind(kind) ? LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
136
+ }
137
+
138
+ /** Map common image extensions to a MIME type for an actor image `mediaType`. */
139
+ const IMAGE_MEDIA_TYPE_BY_EXT: Record<string, string> = {
140
+ png: 'image/png',
141
+ jpg: 'image/jpeg',
142
+ jpeg: 'image/jpeg',
143
+ gif: 'image/gif',
144
+ webp: 'image/webp',
145
+ avif: 'image/avif',
146
+ };
147
+
148
+ /** True when `value` is an absolute `http(s)` URL. */
149
+ function isAbsoluteHttpUrl(value: string): boolean {
150
+ try {
151
+ return /^https?:$/i.test(new URL(value).protocol);
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build an ActivityPub `Image` object from an already-absolute URL, deriving
159
+ * `mediaType` from the URL extension when recognizable (a bare `Image` with a
160
+ * `url` is spec-valid, so an unknown extension simply omits `mediaType` rather
161
+ * than asserting a wrong one). Shared by the actor `icon` (avatar) and `image`
162
+ * (profile banner) builders.
163
+ */
164
+ function apImageObject(url: string): { type: 'Image'; url: string; mediaType?: string } {
165
+ let extension: string | undefined;
166
+ try {
167
+ extension = new URL(url).pathname.split('.').pop()?.toLowerCase();
168
+ } catch {
169
+ extension = url.split('?')[0]?.split('.').pop()?.toLowerCase();
170
+ }
171
+ const mediaType = extension ? IMAGE_MEDIA_TYPE_BY_EXT[extension] : undefined;
172
+ return mediaType ? { type: 'Image', url, mediaType } : { type: 'Image', url };
173
+ }
174
+
175
+ /**
176
+ * App-supplied media resolution for the actor `icon`/`image`. Each function
177
+ * resolves a stored reference (Oxy file id or URL) to a FINAL, ready-to-serve
178
+ * URL, or a falsy value when there is nothing to resolve. The engine enforces the
179
+ * absolute-URL invariant on the result.
180
+ */
181
+ export interface ActorMediaResolver {
182
+ /** Resolve the avatar reference to an absolute URL (actor `icon`). */
183
+ resolveAvatar(ref: string): string | null | undefined;
184
+ /** Resolve the banner reference to an absolute URL (actor `image`). */
185
+ resolveBanner(ref: string): string | null | undefined;
186
+ }
187
+
188
+ /** Adapters + domain config a {@link LocalActorBuilder} is built from. */
189
+ export interface LocalActorBuilderConfig {
190
+ /** The app's federation domain — the host of the actor's human-facing `url`. */
191
+ domain: string;
192
+ /** The per-instance URL builders (actor/inbox/outbox/collections). */
193
+ urls: UrlBuilders;
194
+ /** App-supplied avatar/banner resolution. */
195
+ media: ActorMediaResolver;
196
+ /** Optional sink for the non-fatal "did not resolve to an absolute URL" warning. */
197
+ onWarn?: (message: string) => void;
198
+ }
199
+
200
+ /** Per-user inputs to a {@link LocalActorBuilder}. */
201
+ export interface BuildLocalActorParams {
202
+ username: string;
203
+ /**
204
+ * The account-graph classification (Oxy `User.kind`), which decides the AS2
205
+ * actor `type` via {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}. Absent is read as
206
+ * `personal` — matching the column's own default, and preserving the `Person`
207
+ * every actor carried before the map existed.
208
+ */
209
+ kind?: AccountKind | null;
210
+ /**
211
+ * The caller-resolved Oxy `name.displayName` (falling back to the handle). Never
212
+ * recomposed from name parts here.
213
+ */
214
+ displayName: string;
215
+ bio?: string | null;
216
+ /** The avatar reference (Oxy file id or URL); resolved to the actor `icon`. */
217
+ avatar?: string | null;
218
+ /**
219
+ * The banner reference (from the app's own settings, e.g.
220
+ * `UserSettings.profileHeaderImage`); resolved to the actor `image`.
221
+ */
222
+ profileHeaderImage?: string | null;
223
+ publicKey: { keyId: string; publicKeyPem: string };
224
+ createdAt?: string | null;
225
+ }
226
+
227
+ /**
228
+ * Assembles a LOCAL user's AP actor object (WITHOUT the top-level `@context`).
229
+ * The actor `type` follows the account's kind — see
230
+ * {@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}.
231
+ */
232
+ export type LocalActorBuilder = (params: BuildLocalActorParams) => Record<string, unknown>;
233
+
234
+ /**
235
+ * Build the actor `icon` (avatar) object, enforcing the absolute-URL invariant.
236
+ *
237
+ * ActivityPub consumers such as Mastodon validate that `icon.url` is an absolute
238
+ * URL and REJECT the entire actor document when it is not — so a non-absolute
239
+ * value makes the account undiscoverable. Returns undefined when there is no
240
+ * avatar or no absolute URL can be produced (Mastodon is fine with an
241
+ * avatar-less actor).
242
+ */
243
+ function buildActorIcon(
244
+ config: LocalActorBuilderConfig,
245
+ avatar: string | null | undefined,
246
+ ): { type: 'Image'; url: string; mediaType?: string } | undefined {
247
+ if (!avatar) return undefined;
248
+ const resolved = config.media.resolveAvatar(avatar);
249
+ if (!resolved || !isAbsoluteHttpUrl(resolved)) {
250
+ config.onWarn?.(`[Federation] Omitting actor icon — avatar did not resolve to an absolute URL (ref: ${avatar})`);
251
+ return undefined;
252
+ }
253
+ return apImageObject(resolved);
254
+ }
255
+
256
+ /**
257
+ * Build the actor `image` (profile banner/header) object, enforcing the same
258
+ * absolute-URL invariant as {@link buildActorIcon}. Mastodon renders the AP
259
+ * `image` property as the profile HEADER banner.
260
+ */
261
+ function buildActorImage(
262
+ config: LocalActorBuilderConfig,
263
+ banner: string | null | undefined,
264
+ ): { type: 'Image'; url: string; mediaType?: string } | undefined {
265
+ if (!banner) return undefined;
266
+ const resolved = config.media.resolveBanner(banner);
267
+ if (!resolved || !isAbsoluteHttpUrl(resolved)) {
268
+ config.onWarn?.(`[Federation] Omitting actor image — banner did not resolve to an absolute URL (ref: ${banner})`);
269
+ return undefined;
270
+ }
271
+ return apImageObject(resolved);
272
+ }
273
+
274
+ /**
275
+ * Build the per-instance local-actor builder. Bind it once with an app's domain +
276
+ * media resolver; call the returned function per user.
277
+ */
278
+ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalActorBuilder {
279
+ return (params: BuildLocalActorParams): Record<string, unknown> => {
280
+ const { username, displayName, kind, bio, avatar, profileHeaderImage, publicKey, createdAt } = params;
281
+
282
+ const actorObject: Record<string, unknown> = {
283
+ id: config.urls.actor(username),
284
+ type: localActorTypeForAccountKind(kind),
285
+ preferredUsername: username,
286
+ name: displayName,
287
+ summary: bio || '',
288
+ url: `https://${config.domain}/@${username}`,
289
+ inbox: config.urls.inbox(username),
290
+ outbox: config.urls.outbox(username),
291
+ featured: config.urls.featured(username),
292
+ followers: config.urls.followers(username),
293
+ following: config.urls.following(username),
294
+ endpoints: { sharedInbox: config.urls.sharedInbox() },
295
+ discoverable: true,
296
+ manuallyApprovesFollowers: false,
297
+ icon: buildActorIcon(config, avatar),
298
+ image: buildActorImage(config, profileHeaderImage),
299
+ publicKey: {
300
+ id: publicKey.keyId,
301
+ owner: config.urls.actor(username),
302
+ publicKeyPem: publicKey.publicKeyPem,
303
+ },
304
+ };
305
+
306
+ // `published` (account creation date) is advertised when the API provides it.
307
+ if (createdAt) {
308
+ actorObject.published = new Date(createdAt).toISOString();
309
+ }
310
+
311
+ return actorObject;
312
+ };
313
+ }
@@ -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
+ ];
package/src/apUri.ts ADDED
@@ -0,0 +1,161 @@
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
+
13
+ /** Path segments that typically separate an actor path from a post ID in ActivityPub URIs. */
14
+ const POST_PATH_SEGMENTS = new Set(['statuses', 'posts', 'notes', 'objects', 'activities']);
15
+
16
+ /**
17
+ * THE FORM THIS ENGINE COMPARES HOSTS IN — trimmed, lowercased, one leading
18
+ * `www.` removed, and nothing else.
19
+ *
20
+ * It is exported because it is not an implementation detail: it decides whether
21
+ * two spellings of a host are the SAME host, and {@link createDomainPolicy} —
22
+ * the blocked-domain gate every inbound activity and every actor fetch passes
23
+ * through — is built out of this exact function. A consumer that keeps its own
24
+ * copy of the rule (a moderation blocklist, a transparency page, a content
25
+ * purge) is keeping a second opinion about which hosts are which, and the moment
26
+ * the two drift the consumer acts on domains the engine never refused. For a
27
+ * consumer whose action is irreversible that difference is deleted content.
28
+ *
29
+ * WHAT IT DELIBERATELY DOES NOT DO
30
+ *
31
+ * It does not strip a TRAILING DOT. `example.com.` is the fully-qualified
32
+ * spelling of `example.com` in DNS, but it is a different string here — and
33
+ * also on the wire, because `new URL('https://example.com./x').hostname`
34
+ * preserves the dot and that value is what the engine feeds in. So the two
35
+ * spellings do not match each other, in this function and in the engine
36
+ * alike. Widening that is a POLICY decision (it makes a blocklist match hosts
37
+ * it does not literally name) and belongs to whoever owns the policy, not to
38
+ * a string transform.
39
+ *
40
+ * It does not perform IDNA. The input is expected to be an ASCII host in the
41
+ * form the WHATWG URL parser produces — `new URL(...).hostname` has already
42
+ * applied ToASCII, so an internationalised host arrives as punycode
43
+ * (`xn--ber-goa.example`). A host spelled in unicode is lowercased but NOT
44
+ * converted, so it will not match its own punycode wire form. Callers that
45
+ * accept operator-typed hosts must convert them before comparing.
46
+ *
47
+ * @param host a bare host — no scheme, no port, no path.
48
+ */
49
+ export function canonicalFederationHost(host: string): string {
50
+ const value = host.trim().toLowerCase();
51
+ return value.startsWith('www.') ? value.slice(4) : value;
52
+ }
53
+
54
+ /**
55
+ * Whether two spellings name the same host under {@link canonicalFederationHost}.
56
+ *
57
+ * This is the question a caller actually has ("is the host on this activity the
58
+ * host we blocked?"), and it exists so that asking it does not require each
59
+ * caller to assemble its own comparison around the normaliser. Assembling one is
60
+ * where the mistakes happen, and they are quiet ones: a comparison that
61
+ * lowercases but forgets `www.`, or that allows `www.` on one side only and so
62
+ * answers differently depending on argument order, looks correct at every call
63
+ * site and is wrong for exactly the hosts an evasive instance will use.
64
+ *
65
+ * A blank string names no host, so it matches nothing — including another blank.
66
+ * That is the same answer {@link DomainPolicy.isBlockedDomain} gives it: a host
67
+ * that is not named is not in any set.
68
+ */
69
+ export function isSameFederationHost(a: string, b: string): boolean {
70
+ const canonicalA = canonicalFederationHost(a);
71
+ if (canonicalA.length === 0) return false;
72
+ return canonicalA === canonicalFederationHost(b);
73
+ }
74
+
75
+ /**
76
+ * Given an ActivityPub activity/object ID (URL), extract the actor URI by
77
+ * trimming everything from the first recognised post-path segment onward.
78
+ *
79
+ * e.g. "https://mastodon.social/users/alice/statuses/12345"
80
+ * → "https://mastodon.social/users/alice"
81
+ *
82
+ * Returns null when the URL is malformed or no post-path segment is found.
83
+ */
84
+ export function extractActorUriFromActivityId(activityId: string): string | null {
85
+ try {
86
+ const url = new URL(activityId);
87
+ const segments = url.pathname.split('/').filter(Boolean);
88
+ const statusIdx = segments.findIndex((s) => POST_PATH_SEGMENTS.has(s));
89
+ if (statusIdx < 1) return null;
90
+ return `${url.origin}/${segments.slice(0, statusIdx).join('/')}`;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Configuration for a per-instance {@link DomainPolicy}. */
97
+ export interface DomainPolicyConfig {
98
+ /** The app's federation domain (where it mints webfinger / inbox / collection URIs). */
99
+ domain: string;
100
+ /** The host that owns actor URIs; defaults to `domain`. */
101
+ actorDomain?: string;
102
+ /**
103
+ * Oxy's identity apex (e.g. `oxy.so`). Every Oxy/Mention user is ALSO published
104
+ * as `acct:<username>@<apex>` via the DID layer, so an actor on this host is one
105
+ * of OUR OWN users — resolving it as remote would create duplicate actor rows
106
+ * for local users. Blocked when set.
107
+ */
108
+ identityApex?: string;
109
+ /** Additional explicitly-blocked domains (case-insensitive). */
110
+ blockedDomains?: Iterable<string>;
111
+ }
112
+
113
+ /** Per-instance domain policy: which hosts are ours/blocked, and our own post-URI shape. */
114
+ export interface DomainPolicy {
115
+ /**
116
+ * True when a domain should be rejected for federation — our own ActivityPub
117
+ * domains, the Oxy identity apex (both publish our own users), or an explicitly
118
+ * configured blocked domain.
119
+ */
120
+ isBlockedDomain(domain: string): boolean;
121
+ /**
122
+ * Extract a local Post id from an ActivityPub object URI that points at one of
123
+ * our own posts (`https://<our-domain>/ap/users/<username>/posts/<postId>`).
124
+ * Returns null when the URI host is not one of ours or the path does not match
125
+ * the canonical scheme (the object is remote, resolved by activityId instead).
126
+ */
127
+ extractLocalPostId(objectUri: string): string | null;
128
+ }
129
+
130
+ /**
131
+ * Build the per-instance {@link DomainPolicy} from an app's domain configuration.
132
+ */
133
+ export function createDomainPolicy(config: DomainPolicyConfig): DomainPolicy {
134
+ const localDomains = new Set([
135
+ canonicalFederationHost(config.domain),
136
+ canonicalFederationHost(config.actorDomain ?? config.domain),
137
+ ]);
138
+ const identityApex = config.identityApex ? canonicalFederationHost(config.identityApex) : undefined;
139
+ const blocked = new Set<string>();
140
+ for (const d of config.blockedDomains ?? []) {
141
+ blocked.add(canonicalFederationHost(d));
142
+ }
143
+
144
+ return {
145
+ isBlockedDomain(domain: string): boolean {
146
+ const d = canonicalFederationHost(domain);
147
+ return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
148
+ },
149
+ extractLocalPostId(objectUri: string): string | null {
150
+ let parsed: URL;
151
+ try {
152
+ parsed = new URL(objectUri);
153
+ } catch {
154
+ return null;
155
+ }
156
+ if (!localDomains.has(canonicalFederationHost(parsed.hostname))) return null;
157
+ const match = parsed.pathname.match(/^\/ap\/users\/[^/]+\/posts\/([^/]+)\/?$/);
158
+ return match ? match[1] : null;
159
+ },
160
+ };
161
+ }