@oxy.so/federation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/actorObject.js +216 -0
- package/dist/cjs/apContext.js +48 -0
- package/dist/cjs/apUri.js +132 -0
- package/dist/cjs/httpSignature.js +187 -0
- package/dist/cjs/index.js +99 -0
- package/dist/cjs/networkIdentity.js +487 -0
- package/dist/cjs/node/actorResolver.js +625 -0
- package/dist/cjs/node/actorRouter.js +307 -0
- package/dist/cjs/node/delivery.js +415 -0
- package/dist/cjs/node/identityBridge.js +133 -0
- package/dist/cjs/node/inboundDispatch.js +268 -0
- package/dist/cjs/node/index.js +63 -0
- package/dist/cjs/node/signedFetch.js +122 -0
- package/dist/cjs/node/webfingerRouter.js +166 -0
- package/dist/cjs/urls.js +55 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/actorObject.js +210 -0
- package/dist/esm/apContext.js +45 -0
- package/dist/esm/apUri.js +126 -0
- package/dist/esm/httpSignature.js +179 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/networkIdentity.js +472 -0
- package/dist/esm/node/actorResolver.js +620 -0
- package/dist/esm/node/actorRouter.js +304 -0
- package/dist/esm/node/delivery.js +412 -0
- package/dist/esm/node/identityBridge.js +130 -0
- package/dist/esm/node/inboundDispatch.js +263 -0
- package/dist/esm/node/index.js +51 -0
- package/dist/esm/node/signedFetch.js +119 -0
- package/dist/esm/node/webfingerRouter.js +163 -0
- package/dist/esm/urls.js +50 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/actorObject.d.ts +182 -0
- package/dist/types/apContext.d.ts +35 -0
- package/dist/types/apUri.d.ts +107 -0
- package/dist/types/httpSignature.d.ts +113 -0
- package/dist/types/index.d.ts +336 -0
- package/dist/types/networkIdentity.d.ts +509 -0
- package/dist/types/node/actorResolver.d.ts +287 -0
- package/dist/types/node/actorRouter.d.ts +108 -0
- package/dist/types/node/delivery.d.ts +248 -0
- package/dist/types/node/identityBridge.d.ts +84 -0
- package/dist/types/node/inboundDispatch.d.ts +156 -0
- package/dist/types/node/index.d.ts +51 -0
- package/dist/types/node/signedFetch.d.ts +74 -0
- package/dist/types/node/webfingerRouter.d.ts +62 -0
- package/dist/types/urls.d.ts +55 -0
- package/package.json +119 -0
- package/src/__tests__/actorObject.test.ts +258 -0
- package/src/__tests__/actorResolver.test.ts +252 -0
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
- package/src/__tests__/apUri.test.ts +53 -0
- package/src/__tests__/delivery.test.ts +432 -0
- package/src/__tests__/federationHost.test.ts +281 -0
- package/src/__tests__/httpSignature.test.ts +343 -0
- package/src/__tests__/inboundDispatch.test.ts +381 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/networkIdentity.test.ts +525 -0
- package/src/__tests__/routers.test.ts +460 -0
- package/src/__tests__/urls.test.ts +26 -0
- package/src/actorObject.ts +313 -0
- package/src/apContext.ts +45 -0
- package/src/apUri.ts +161 -0
- package/src/httpSignature.ts +282 -0
- package/src/index.ts +419 -0
- package/src/networkIdentity.ts +731 -0
- package/src/node/actorResolver.ts +839 -0
- package/src/node/actorRouter.ts +438 -0
- package/src/node/delivery.ts +729 -0
- package/src/node/identityBridge.ts +230 -0
- package/src/node/inboundDispatch.ts +420 -0
- package/src/node/index.ts +136 -0
- package/src/node/signedFetch.ts +177 -0
- package/src/node/webfingerRouter.ts +226 -0
- package/src/urls.ts +71 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/apContext.ts
ADDED
|
@@ -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
|
+
}
|