@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,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH HOSTS REPUBLISH ANOTHER NETWORK'S ACCOUNTS, AND HOW TO READ THE REAL
|
|
3
|
+
* IDENTITY BACK OUT OF THEM.
|
|
4
|
+
*
|
|
5
|
+
* A BRIDGE is a fediverse host that mirrors accounts from somewhere else. The
|
|
6
|
+
* account it publishes as `@WIRED@mastox.eu` is not a person on mastox.eu — it is
|
|
7
|
+
* WIRED, on X, copied. Naming that account after the bridge tells a reader
|
|
8
|
+
* nothing they can act on: the hostname is an implementation detail of how the
|
|
9
|
+
* post reached us, and the thing they actually want to know is which account on
|
|
10
|
+
* which network wrote it. So an actor from a listed bridge is stored and rendered
|
|
11
|
+
* under the NETWORK it came from — `@wired@x.com` — exactly as an atproto actor
|
|
12
|
+
* with a custom-domain handle is stored under `bsky.social` rather than under the
|
|
13
|
+
* domain the handle happens to spell.
|
|
14
|
+
*
|
|
15
|
+
* WHY THE MECHANISM LIVES HERE BUT THE ENTRIES DO NOT
|
|
16
|
+
*
|
|
17
|
+
* Two different questions must stay separate. An app's connector DERIVES the
|
|
18
|
+
* identity at ingest (`createBridgeRelabeller(entries)` with entries the app
|
|
19
|
+
* commits and answers for), and oxy-api's `PUT /users/resolve` DECIDES
|
|
20
|
+
* WHETHER TO BELIEVE IT — that endpoint binds an actor URI's hostname to the
|
|
21
|
+
* domain the caller asserts, precisely so a service cannot claim to vouch for
|
|
22
|
+
* a user on a host it does not own. A bridged identity is the one case where
|
|
23
|
+
* those legitimately differ. The shared package ships the derivation machinery
|
|
24
|
+
* and network vocabulary; each side keeps its own reviewed list and they fail
|
|
25
|
+
* CLOSED in both directions — an app that derives for a bridge the API does
|
|
26
|
+
* not trust simply has its resolve refused, and a host the API trusts that no
|
|
27
|
+
* app derives for does nothing at all.
|
|
28
|
+
*
|
|
29
|
+
* A WRONG ENTRY HERE MISATTRIBUTES SOMEBODY'S WRITING
|
|
30
|
+
*
|
|
31
|
+
* That is a heavier failure than the blocklist's. A wrong block loses content
|
|
32
|
+
* and somebody complains; a wrong bridge entry silently publishes one person's
|
|
33
|
+
* posts under another person's name, on a network they may not even use. So
|
|
34
|
+
* every entry records what was actually VERIFIED against a live actor
|
|
35
|
+
* ({@link FederationBridgeEntry.evidence}) separately from what is merely
|
|
36
|
+
* ASSUMED ({@link FederationBridgeEntry.assumption}), and every entry an app
|
|
37
|
+
* ships should carry a stored fixture and a test that fails if its rule stops
|
|
38
|
+
* round-tripping. Derivation is per-ACTOR and fails closed: an actor that does
|
|
39
|
+
* not satisfy its bridge's rule keeps the bridge hostname, because a bridge's
|
|
40
|
+
* own admin and service accounts are real accounts on that host and relabelling
|
|
41
|
+
* them would invent an upstream person who does not exist.
|
|
42
|
+
*
|
|
43
|
+
* THIS IS NOT THE BLOCKLIST, AND MUST NEVER BE MERGED WITH IT
|
|
44
|
+
*
|
|
45
|
+
* Blocking and bridge-trust are opposite decisions about a host, and the
|
|
46
|
+
* blocklist wins: a blocked host is refused before any actor from it is ever
|
|
47
|
+
* built, so no relabel can resurrect it. Keeping them in separate structures
|
|
48
|
+
* means neither can be edited into the other by accident.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { canonicalFederationHost } from './apUri';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A network accounts can be bridged FROM.
|
|
55
|
+
*
|
|
56
|
+
* `domain` is the identity domain — the part after the `@` in a rendered handle,
|
|
57
|
+
* and the `domain` bound to a federated Oxy username. It is the network's
|
|
58
|
+
* canonical public host (`x.com`, not `twitter.com`), because that is what a
|
|
59
|
+
* reader recognises and what a profile link resolves to.
|
|
60
|
+
*/
|
|
61
|
+
export interface FederationNetwork {
|
|
62
|
+
/** Stable key for this network (used to group bridges that mirror the same one). */
|
|
63
|
+
readonly id: string;
|
|
64
|
+
/** Human name, for logs and review output. */
|
|
65
|
+
readonly name: string;
|
|
66
|
+
/** The identity domain handles are rendered and stored under. */
|
|
67
|
+
readonly domain: string;
|
|
68
|
+
/**
|
|
69
|
+
* Every host a profile URL for this network can be pasted from — the canonical
|
|
70
|
+
* one FIRST, then aliases (`x.com` and `twitter.com` are one network;
|
|
71
|
+
* `instagram.com` and `www.instagram.com` differ only by a prefix the
|
|
72
|
+
* canonicaliser already strips).
|
|
73
|
+
*/
|
|
74
|
+
readonly profileHosts: readonly string[];
|
|
75
|
+
/** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
|
|
76
|
+
readonly profilePathPrefix: readonly string[];
|
|
77
|
+
/**
|
|
78
|
+
* The LOCAL PART an upstream handle is stored under in Oxy.
|
|
79
|
+
*
|
|
80
|
+
* Not the identity function: X and Instagram treat handles
|
|
81
|
+
* case-insensitively so they are lowered, and a default Bluesky handle drops
|
|
82
|
+
* its now-redundant `.bsky.social` suffix once the domain already says
|
|
83
|
+
* `bsky.social`. It belongs to the NETWORK rather than to any bridge entry
|
|
84
|
+
* because it is a protocol fact about how that network names accounts, not a
|
|
85
|
+
* judgement about an operator.
|
|
86
|
+
*
|
|
87
|
+
* Both the ingest path and the search path go through this ONE function.
|
|
88
|
+
* That is the whole point: a pasted `https://bsky.app/profile/x.bsky.social`
|
|
89
|
+
* has to arrive at the same username the connector stored, or search finds
|
|
90
|
+
* nothing and looks exactly like "we do not have that account".
|
|
91
|
+
*/
|
|
92
|
+
readonly storedUsername: (handle: string) => string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The networks Oxy re-labels accounts onto.
|
|
97
|
+
*
|
|
98
|
+
* Bluesky is here for a reason beyond bridging: it is the network the atproto
|
|
99
|
+
* connector ingests DIRECTLY, and its domain used to be a constant private to
|
|
100
|
+
* that connector. Both readers now take it from here, so a Bluesky account
|
|
101
|
+
* reaching us over atproto and the same account reaching us over ActivityPub
|
|
102
|
+
* through Bridgy Fed cannot end up under two different domains — which is what
|
|
103
|
+
* would happen if the two paths each named the network themselves.
|
|
104
|
+
*/
|
|
105
|
+
export const FEDERATION_NETWORKS = {
|
|
106
|
+
x: {
|
|
107
|
+
id: 'x',
|
|
108
|
+
name: 'X',
|
|
109
|
+
domain: 'x.com',
|
|
110
|
+
profileHosts: ['x.com', 'twitter.com', 'mobile.twitter.com', 'mobile.x.com'],
|
|
111
|
+
profilePathPrefix: [],
|
|
112
|
+
storedUsername: (handle) => handle.trim().toLowerCase(),
|
|
113
|
+
},
|
|
114
|
+
instagram: {
|
|
115
|
+
id: 'instagram',
|
|
116
|
+
name: 'Instagram',
|
|
117
|
+
domain: 'instagram.com',
|
|
118
|
+
profileHosts: ['instagram.com'],
|
|
119
|
+
profilePathPrefix: [],
|
|
120
|
+
storedUsername: (handle) => handle.trim().toLowerCase(),
|
|
121
|
+
},
|
|
122
|
+
bluesky: {
|
|
123
|
+
id: 'bluesky',
|
|
124
|
+
name: 'Bluesky',
|
|
125
|
+
domain: 'bsky.social',
|
|
126
|
+
profileHosts: ['bsky.app'],
|
|
127
|
+
profilePathPrefix: ['profile'],
|
|
128
|
+
storedUsername: (handle) => blueskyUsernameFromHandle(handle.trim()),
|
|
129
|
+
},
|
|
130
|
+
} as const satisfies Record<string, FederationNetwork>;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The upstream profile URL for a handle on a network.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately the SAME declaration {@link parseUpstreamProfileUrl} reads
|
|
136
|
+
* backwards. Rendering a link and recognising a pasted one are the same fact
|
|
137
|
+
* stated in two directions, and holding them as two independent tables is how
|
|
138
|
+
* they drift — with the failure landing on the parsing side, where a search that
|
|
139
|
+
* silently finds nothing is indistinguishable from "we do not have that account"
|
|
140
|
+
* and so nobody ever reports it.
|
|
141
|
+
*/
|
|
142
|
+
export function upstreamProfileUrl(network: FederationNetwork, handle: string): string {
|
|
143
|
+
const path = [...network.profilePathPrefix, encodeURIComponent(handle)].join('/');
|
|
144
|
+
return `https://${network.profileHosts[0]}/${path}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The network and handle a pasted upstream profile URL names, or `undefined` when
|
|
149
|
+
* it is not one.
|
|
150
|
+
*
|
|
151
|
+
* Query strings and fragments are dropped (a pasted URL usually carries tracking
|
|
152
|
+
* parameters) and a trailing slash is tolerated. Purely syntactic: it never
|
|
153
|
+
* fetches the URL — resolving a user-supplied URL by fetching it would be an SSRF
|
|
154
|
+
* surface, and there is nothing here that needs the network.
|
|
155
|
+
*/
|
|
156
|
+
export function parseUpstreamProfileUrl(
|
|
157
|
+
candidateUrl: string,
|
|
158
|
+
networks: readonly FederationNetwork[] = Object.values(FEDERATION_NETWORKS),
|
|
159
|
+
): { network: FederationNetwork; handle: string } | undefined {
|
|
160
|
+
let url: URL;
|
|
161
|
+
try {
|
|
162
|
+
url = new URL(candidateUrl.trim());
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
167
|
+
|
|
168
|
+
const host = canonicalFederationHost(url.hostname);
|
|
169
|
+
for (const network of networks) {
|
|
170
|
+
if (!network.profileHosts.some((allowed) => canonicalFederationHost(allowed) === host)) continue;
|
|
171
|
+
const handle = profileUrlHandle(url.href, network.profileHosts, network.profilePathPrefix);
|
|
172
|
+
if (handle !== undefined && handle.length > 0) return { network, handle };
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The Bluesky network's canonical identity domain — see {@link FEDERATION_NETWORKS}. */
|
|
178
|
+
export const BSKY_NETWORK_DOMAIN = FEDERATION_NETWORKS.bluesky.domain;
|
|
179
|
+
|
|
180
|
+
/** A profile field as the actor cache stores it (only what a derivation rule reads). */
|
|
181
|
+
export interface BridgedActorField {
|
|
182
|
+
readonly name: string;
|
|
183
|
+
readonly value: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* One FEP-fffd `proxyOf` entry: an actor stating, in machine-readable form, that
|
|
188
|
+
* it is a proxy for an object on another protocol.
|
|
189
|
+
*
|
|
190
|
+
* Observed on the wire (momostr.pink, 2026-08-02):
|
|
191
|
+
*
|
|
192
|
+
* "proxyOf": [{
|
|
193
|
+
* "protocol": "https://github.com/nostr-protocol/nostr",
|
|
194
|
+
* "proxied": "npub1sg6plz…",
|
|
195
|
+
* "authoritative": true
|
|
196
|
+
* }]
|
|
197
|
+
*
|
|
198
|
+
* `protocol` is a URI naming the upstream protocol, `proxied` its identifier
|
|
199
|
+
* there, and `authoritative` says whether this actor is the canonical
|
|
200
|
+
* representation rather than one copy among several.
|
|
201
|
+
*/
|
|
202
|
+
export interface ProxyDeclaration {
|
|
203
|
+
readonly protocol: string;
|
|
204
|
+
readonly proxied: string;
|
|
205
|
+
readonly authoritative: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse an actor's `proxyOf` into well-formed declarations, dropping anything
|
|
210
|
+
* malformed. Pure; accepts `unknown` because it reads an untrusted document.
|
|
211
|
+
*
|
|
212
|
+
* `authoritative` DEFAULTS TO FALSE when absent. FEP-fffd leaves it optional, and
|
|
213
|
+
* the conservative reading is the only safe one here: the flag is what
|
|
214
|
+
* distinguishes "this actor IS that upstream account" from "this actor is one
|
|
215
|
+
* copy of it", and only the former could ever justify moving an identity.
|
|
216
|
+
*/
|
|
217
|
+
export function readProxyDeclarations(value: unknown): ProxyDeclaration[] {
|
|
218
|
+
if (!Array.isArray(value)) return [];
|
|
219
|
+
const declarations: ProxyDeclaration[] = [];
|
|
220
|
+
for (const raw of value) {
|
|
221
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
222
|
+
const entry = raw as Record<string, unknown>;
|
|
223
|
+
const protocol = typeof entry.protocol === 'string' ? entry.protocol.trim() : '';
|
|
224
|
+
const proxied = typeof entry.proxied === 'string' ? entry.proxied.trim() : '';
|
|
225
|
+
if (protocol.length === 0 || proxied.length === 0) continue;
|
|
226
|
+
declarations.push({ protocol, proxied, authoritative: entry.authoritative === true });
|
|
227
|
+
}
|
|
228
|
+
return declarations;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Everything a derivation rule may look at. Every field is a value the caller has
|
|
233
|
+
* already derived and verified, so a rule never re-parses the actor document.
|
|
234
|
+
*/
|
|
235
|
+
export interface NetworkIdentityCandidate {
|
|
236
|
+
/** The lowercase host the actor is authoritative for (post-redirect). */
|
|
237
|
+
readonly host: string;
|
|
238
|
+
/** The canonical `user@host` acct — the actor's PROTOCOL address. */
|
|
239
|
+
readonly acct: string;
|
|
240
|
+
/** `preferredUsername` verbatim, case preserved. */
|
|
241
|
+
readonly preferredUsername: string;
|
|
242
|
+
/** The actor's own `id`. */
|
|
243
|
+
readonly actorUri: string;
|
|
244
|
+
/** The AP `type` (`Person` / `Service` / `Application` / …). */
|
|
245
|
+
readonly actorType: string;
|
|
246
|
+
/** `alsoKnownAs`, verbatim (empty when the actor publishes none). */
|
|
247
|
+
readonly alsoKnownAs: readonly string[];
|
|
248
|
+
/** The actor's profile fields (PropertyValue), already sanitized. */
|
|
249
|
+
readonly fields: readonly BridgedActorField[];
|
|
250
|
+
/** The actor's FEP-fffd `proxyOf` declarations (empty when it publishes none). */
|
|
251
|
+
readonly proxyOf: readonly ProxyDeclaration[];
|
|
252
|
+
/** The actor's bio as plain text. */
|
|
253
|
+
readonly bio: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* An actor re-labelled onto the network its identity really belongs to.
|
|
258
|
+
*
|
|
259
|
+
* `federatedUsername` MUST end with `@${instanceDomain}` — oxy-api binds a
|
|
260
|
+
* federated username to its domain — and the caller REFUSES a result that does
|
|
261
|
+
* not, rather than minting an identity oxy-api would reject.
|
|
262
|
+
*/
|
|
263
|
+
export interface NetworkIdentity {
|
|
264
|
+
/** The canonical `<user>@<network-domain>` identity (e.g. `wired@x.com`). */
|
|
265
|
+
readonly federatedUsername: string;
|
|
266
|
+
/** The network domain the identity belongs to (e.g. `x.com`). */
|
|
267
|
+
readonly instanceDomain: string;
|
|
268
|
+
/** The bio with the bridge's own appended boilerplate removed. */
|
|
269
|
+
readonly bio: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* App-supplied re-labelling of an ingested actor onto its real network. Returns
|
|
274
|
+
* `undefined` for anything not recognised — which is the overwhelmingly common
|
|
275
|
+
* case, and the correct answer whenever the derivation is not certain.
|
|
276
|
+
*/
|
|
277
|
+
export type DeriveNetworkIdentity = (
|
|
278
|
+
candidate: NetworkIdentityCandidate,
|
|
279
|
+
) => NetworkIdentity | undefined;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Whether the people mirrored by a bridge asked to be.
|
|
283
|
+
*
|
|
284
|
+
* Recorded because it is the question a mirrored person asks first, and because
|
|
285
|
+
* it changes what a reasonable response to a complaint is. It deliberately does
|
|
286
|
+
* NOT gate the relabel: an unconsented mirror is still that person's writing, and
|
|
287
|
+
* attributing it to them is more honest than attributing it to the bridge.
|
|
288
|
+
*
|
|
289
|
+
* - `opt-in` the upstream account took an action to enable the bridge.
|
|
290
|
+
* - `unconsented` the bridge mirrors without asking; removal is on request.
|
|
291
|
+
*/
|
|
292
|
+
export type BridgeConsentModel = 'opt-in' | 'unconsented';
|
|
293
|
+
|
|
294
|
+
/** How the upstream handle is recovered from a bridged actor. */
|
|
295
|
+
export type BridgeDerivation = (candidate: NetworkIdentityCandidate) => string | undefined;
|
|
296
|
+
|
|
297
|
+
/** One reviewed bridge. */
|
|
298
|
+
export interface FederationBridgeEntry {
|
|
299
|
+
/**
|
|
300
|
+
* The bridge's host, CANONICAL — lowercase, bare host, no scheme, no `www.`.
|
|
301
|
+
* Matching is exact canonical-host membership, so a subdomain is a different
|
|
302
|
+
* host and needs its own reviewed entry.
|
|
303
|
+
*/
|
|
304
|
+
readonly host: string;
|
|
305
|
+
/** The network accounts here are mirrored FROM. */
|
|
306
|
+
readonly network: FederationNetwork;
|
|
307
|
+
/** Who runs the bridge, as they identify themselves. */
|
|
308
|
+
readonly operator: string;
|
|
309
|
+
/** The bridge software, as its own nodeinfo reports it. */
|
|
310
|
+
readonly software: string;
|
|
311
|
+
/**
|
|
312
|
+
* Recover the upstream handle from ONE actor, or `undefined` when this actor
|
|
313
|
+
* does not satisfy the rule — which is how the bridge's own admin/service
|
|
314
|
+
* accounts, and anything whose shape changed, are left alone.
|
|
315
|
+
*/
|
|
316
|
+
readonly derive: BridgeDerivation;
|
|
317
|
+
/**
|
|
318
|
+
* What to do with the recovered handle's case before storing it.
|
|
319
|
+
*
|
|
320
|
+
* - `lowercase` the upstream network treats handles case-insensitively, so
|
|
321
|
+
* lowercasing loses no addressability and keeps the handle rendering like
|
|
322
|
+
* every other federated handle (AP acct normalisation lowercases them all).
|
|
323
|
+
* - `preserve` the handle is a DNS name and is already canonical; touching it
|
|
324
|
+
* would change what it addresses.
|
|
325
|
+
*/
|
|
326
|
+
readonly caseRule: 'lowercase' | 'preserve';
|
|
327
|
+
/**
|
|
328
|
+
* Whether re-labelling this bridge's actors is SAFE TO APPLY yet.
|
|
329
|
+
*
|
|
330
|
+
* Re-labelling does not merely coexist with duplicates, it MANUFACTURES them:
|
|
331
|
+
* two bridges of one network that render as visibly different accounts today
|
|
332
|
+
* both render the SAME handle afterwards — identical, adjacent in search and in
|
|
333
|
+
* follow lists, and reading as a bug in a way the status quo does not. Where a
|
|
334
|
+
* network's collision set is non-empty, the merge has to land first.
|
|
335
|
+
*
|
|
336
|
+
* - `enabled` the identity moves.
|
|
337
|
+
* - `pending_dedup` the entry is committed and reviewed, and deliberately
|
|
338
|
+
* INERT: the derivation is exercised by tests but no actor
|
|
339
|
+
* is re-labelled, because doing so would create twins.
|
|
340
|
+
*/
|
|
341
|
+
readonly relabel: 'enabled' | 'pending_dedup';
|
|
342
|
+
/**
|
|
343
|
+
* How stable the upstream identifier this rule derives actually is.
|
|
344
|
+
*
|
|
345
|
+
* - `stable` an immutable upstream id (an atproto DID, an ORCID iD). Two
|
|
346
|
+
* rows sharing it are the same account, permanently.
|
|
347
|
+
* - `recyclable` a HANDLE. X and Instagram release abandoned handles, so two
|
|
348
|
+
* bridges capturing years apart can derive the same key for two
|
|
349
|
+
* different humans, and Bluesky handles are mutable — which is
|
|
350
|
+
* exactly why the atproto connector keys on the DID instead.
|
|
351
|
+
*
|
|
352
|
+
* Named rather than silently carried: it is the residual risk a merge inherits,
|
|
353
|
+
* and a reader deciding whether to trust a merge needs it stated.
|
|
354
|
+
*/
|
|
355
|
+
readonly upstreamIdStability: 'stable' | 'recyclable';
|
|
356
|
+
/**
|
|
357
|
+
* The bridge's own appended boilerplate, to strip from the bio.
|
|
358
|
+
*
|
|
359
|
+
* Per-bridge and anchored, never a general "looks like boilerplate" heuristic:
|
|
360
|
+
* a pattern that does not match leaves the bio EXACTLY as written, which is the
|
|
361
|
+
* only safe behaviour when the alternative is deleting a line the author wrote.
|
|
362
|
+
* Several bridges emit the same notice in more than one language, so this is a
|
|
363
|
+
* list and every variant that has been observed is listed.
|
|
364
|
+
*/
|
|
365
|
+
readonly boilerplate: readonly RegExp[];
|
|
366
|
+
/** Whether the mirrored accounts asked to be mirrored. */
|
|
367
|
+
readonly consent: BridgeConsentModel;
|
|
368
|
+
/** What was VERIFIED against a live actor, and where the fixture came from. */
|
|
369
|
+
readonly evidence: string;
|
|
370
|
+
/**
|
|
371
|
+
* What is ASSUMED rather than verified. Empty string means the derivation reads
|
|
372
|
+
* an assertion the actor itself publishes, so nothing is being guessed.
|
|
373
|
+
*/
|
|
374
|
+
readonly assumption: string;
|
|
375
|
+
/** `YYYY-MM-DD` — the day the entry took effect. */
|
|
376
|
+
readonly since: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The handle a profile URL addresses: the single path segment that follows the
|
|
381
|
+
* network's fixed profile prefix (`x.com/<handle>` has none, `bsky.app` uses
|
|
382
|
+
* `profile/`), on one of the network's own hosts.
|
|
383
|
+
*
|
|
384
|
+
* Exact — one segment after the prefix and nothing more — so a link to some other
|
|
385
|
+
* page on the same host (`x.com/i/status/123`) yields nothing rather than a
|
|
386
|
+
* plausible-looking wrong handle.
|
|
387
|
+
*/
|
|
388
|
+
function profileUrlHandle(
|
|
389
|
+
href: string,
|
|
390
|
+
allowedHosts: readonly string[],
|
|
391
|
+
pathPrefix: readonly string[],
|
|
392
|
+
): string | undefined {
|
|
393
|
+
let url: URL;
|
|
394
|
+
try {
|
|
395
|
+
url = new URL(href);
|
|
396
|
+
} catch {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
400
|
+
const host = canonicalFederationHost(url.hostname);
|
|
401
|
+
if (!allowedHosts.some((allowed) => canonicalFederationHost(allowed) === host)) return undefined;
|
|
402
|
+
|
|
403
|
+
const segments = url.pathname.split('/').filter((s) => s.length > 0);
|
|
404
|
+
if (segments.length !== pathPrefix.length + 1) return undefined;
|
|
405
|
+
for (let i = 0; i < pathPrefix.length; i += 1) {
|
|
406
|
+
if (segments[i].toLowerCase() !== pathPrefix[i]) return undefined;
|
|
407
|
+
}
|
|
408
|
+
return decodeURIComponent(segments[pathPrefix.length]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Every `href="…"` in a sanitized field value, in document order. */
|
|
412
|
+
function fieldHrefs(value: string): string[] {
|
|
413
|
+
const hrefs: string[] = [];
|
|
414
|
+
const pattern = /href="([^"]*)"/gi;
|
|
415
|
+
let match = pattern.exec(value);
|
|
416
|
+
while (match !== null) {
|
|
417
|
+
hrefs.push(match[1]);
|
|
418
|
+
match = pattern.exec(value);
|
|
419
|
+
}
|
|
420
|
+
return hrefs;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Read the upstream handle out of a named profile field that links to the
|
|
425
|
+
* upstream profile — the STRONGEST rule available, because the bridge is
|
|
426
|
+
* publishing a machine-readable assertion of which account this mirrors rather
|
|
427
|
+
* than leaving us to infer it from the username.
|
|
428
|
+
*/
|
|
429
|
+
export function upstreamHandleFromProfileField(options: {
|
|
430
|
+
readonly fieldName: string;
|
|
431
|
+
readonly hosts: readonly string[];
|
|
432
|
+
/** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
|
|
433
|
+
readonly pathPrefix?: readonly string[];
|
|
434
|
+
}): BridgeDerivation {
|
|
435
|
+
const wanted = options.fieldName.toLowerCase();
|
|
436
|
+
const prefix = options.pathPrefix ?? [];
|
|
437
|
+
return (candidate) => {
|
|
438
|
+
for (const field of candidate.fields) {
|
|
439
|
+
if (field.name.trim().toLowerCase() !== wanted) continue;
|
|
440
|
+
for (const href of fieldHrefs(field.value)) {
|
|
441
|
+
const handle = profileUrlHandle(href, options.hosts, prefix);
|
|
442
|
+
if (handle !== undefined && handle.length > 0) return handle;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Read the upstream handle out of `alsoKnownAs` profile URLs — the shape where an
|
|
451
|
+
* actor publishes a profile link there rather than in a named profile field.
|
|
452
|
+
*
|
|
453
|
+
* ⚠ UNMATCHED BY ANY ACTOR WE ACTUALLY HOLD. On all three Bridgy Fed actors
|
|
454
|
+
* captured from production, `alsoKnownAs` contains ONLY the atproto DID
|
|
455
|
+
* (`["did:plc:…"]`) and no `bsky.app` URL, so this returns `undefined` for the
|
|
456
|
+
* entire real corpus; the shipped Bridgy entry reads the `Web site` profile
|
|
457
|
+
* field, which every one of them does carry. Written against the documented
|
|
458
|
+
* shape rather than an observed one — so verify against a live actor before
|
|
459
|
+
* building on it, and do not read a green test suite as evidence that it fires.
|
|
460
|
+
*
|
|
461
|
+
* `alsoKnownAs` is also NOT a generic upstream backlink. It is one on Bridgy,
|
|
462
|
+
* but on the stock-Mastodon mirror farms it is a Mastodon MIGRATION pointer at a
|
|
463
|
+
* sibling farm domain — following it there would attribute an account to
|
|
464
|
+
* whatever that pointer happens to name. Only use this where a reviewed entry
|
|
465
|
+
* states that the bridge publishes an upstream link in that field.
|
|
466
|
+
*/
|
|
467
|
+
export function upstreamHandleFromAlsoKnownAs(options: {
|
|
468
|
+
readonly hosts: readonly string[];
|
|
469
|
+
/** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
|
|
470
|
+
readonly pathPrefix?: readonly string[];
|
|
471
|
+
}): BridgeDerivation {
|
|
472
|
+
const prefix = options.pathPrefix ?? [];
|
|
473
|
+
return (candidate) => {
|
|
474
|
+
for (const href of candidate.alsoKnownAs) {
|
|
475
|
+
const handle = profileUrlHandle(href, options.hosts, prefix);
|
|
476
|
+
if (handle !== undefined && handle.length > 0) return handle;
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Read the upstream identifier out of an actor's FEP-fffd `proxyOf` declaration.
|
|
484
|
+
*
|
|
485
|
+
* THIS IS A STRATEGY A REVIEWED ENTRY OPTS INTO — NOT A REGISTRY-FREE LANE, AND
|
|
486
|
+
* THE DIFFERENCE IS THE WHOLE SECURITY ARGUMENT.
|
|
487
|
+
*
|
|
488
|
+
* `proxyOf` is attractive precisely because it is self-describing: the actor
|
|
489
|
+
* states what it proxies, in a ratified format, with no list to maintain. That
|
|
490
|
+
* is exactly why it cannot be believed on its own. It is a claim made by an
|
|
491
|
+
* UNTRUSTED REMOTE ACTOR about its own identity, and every field in it is
|
|
492
|
+
* attacker-controlled. Honouring it wherever it appears would mean any actor on
|
|
493
|
+
* any instance could publish
|
|
494
|
+
*
|
|
495
|
+
* "proxyOf": [{ "protocol": "…", "proxied": "elonmusk", "authoritative": true }]
|
|
496
|
+
*
|
|
497
|
+
* and be stored, rendered and searchable as that person on that network. The
|
|
498
|
+
* reviewed bridge list is not bureaucracy around this; it IS the thing that
|
|
499
|
+
* makes a re-attribution believable, because we checked who runs the host.
|
|
500
|
+
*
|
|
501
|
+
* Inside an entry the claim is safe for the same reason the entry's other rules
|
|
502
|
+
* are: we already decided we believe this operator about who it mirrors. So the
|
|
503
|
+
* strategy exists, it is tested, and it is reachable only from a host somebody
|
|
504
|
+
* reviewed.
|
|
505
|
+
*
|
|
506
|
+
* `authoritative` must be true — a non-authoritative proxy says the actor is one
|
|
507
|
+
* copy of the upstream object, not that it stands in for it.
|
|
508
|
+
*
|
|
509
|
+
* NOTHING WE INGEST USES THIS YET. The only actors in our corpus that publish
|
|
510
|
+
* `proxyOf` are the two Nostr bridges, and Nostr identities are npubs with no
|
|
511
|
+
* `@handle@domain` form to re-label onto, so no shipped entry names it. It is
|
|
512
|
+
* here so a bridge that adopts FEP-fffd needs an entry rather than new code —
|
|
513
|
+
* do not read its passing tests as evidence that it fires in production.
|
|
514
|
+
*/
|
|
515
|
+
export function upstreamHandleFromProxyOf(options: {
|
|
516
|
+
/** The protocol URIs this entry accepts, exactly as the actor spells them. */
|
|
517
|
+
readonly protocols: readonly string[];
|
|
518
|
+
/** Map the raw `proxied` identifier to an upstream handle; omit for verbatim. */
|
|
519
|
+
readonly handleFromProxied?: (proxied: string) => string | undefined;
|
|
520
|
+
}): BridgeDerivation {
|
|
521
|
+
const accepted = new Set(options.protocols.map((protocol) => protocol.trim().toLowerCase()));
|
|
522
|
+
const toHandle = options.handleFromProxied ?? ((proxied: string) => proxied);
|
|
523
|
+
return (candidate) => {
|
|
524
|
+
for (const declaration of candidate.proxyOf) {
|
|
525
|
+
if (!declaration.authoritative) continue;
|
|
526
|
+
if (!accepted.has(declaration.protocol.trim().toLowerCase())) continue;
|
|
527
|
+
const handle = toHandle(declaration.proxied);
|
|
528
|
+
if (handle !== undefined && handle.length > 0) return handle;
|
|
529
|
+
}
|
|
530
|
+
return undefined;
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Use the actor's own `preferredUsername` as the upstream handle, but ONLY for an
|
|
536
|
+
* actor that carries one of the bridge's mirror notices.
|
|
537
|
+
*
|
|
538
|
+
* The notice is what distinguishes a mirrored account from a real account on the
|
|
539
|
+
* bridge host: the operator's own admin account lives there too and is not a
|
|
540
|
+
* mirror of anything. Without the marker requirement this rule would relabel that
|
|
541
|
+
* person onto a network they may not even be on.
|
|
542
|
+
*/
|
|
543
|
+
/**
|
|
544
|
+
* A mirror identified by the actor DECLARING ITSELF AUTOMATED, with the handle
|
|
545
|
+
* read from `preferredUsername`.
|
|
546
|
+
*
|
|
547
|
+
* For a bridge that runs stock server software there is nothing to fingerprint:
|
|
548
|
+
* somebody points a mirror bot at an ordinary instance and the result is
|
|
549
|
+
* indistinguishable from any other server. The tempting fallback is to match the
|
|
550
|
+
* per-account notice such a bridge writes into each bio — and that fails, because
|
|
551
|
+
* a notice is free text with LANGUAGES. One deployment served the same sentence
|
|
552
|
+
* in English, French and Spanish; an entry listing two of them silently left
|
|
553
|
+
* every account of the third under the bridge's own hostname, with the notice
|
|
554
|
+
* still in its bio, looking exactly like an ordinary account.
|
|
555
|
+
*
|
|
556
|
+
* `type` is the same claim without the prose. ActivityPub already distinguishes
|
|
557
|
+
* an automated actor (`Service`/`Application`) from a `Person`, every mirror is
|
|
558
|
+
* published as one, and the operator's own account is not — so the bridge's
|
|
559
|
+
* machine-readable declaration replaces a guess about wording. It is still a
|
|
560
|
+
* per-ACTOR proof, which is what keeps a human on that host from being
|
|
561
|
+
* re-attributed to another network.
|
|
562
|
+
*
|
|
563
|
+
* NOT a general "this actor is a bot" rule: it is only ever consulted for a host
|
|
564
|
+
* already reviewed into a bridge policy. Plenty of ordinary fediverse accounts
|
|
565
|
+
* are `Service`, and none of them are on a listed bridge.
|
|
566
|
+
*/
|
|
567
|
+
export function upstreamHandleFromAutomatedActor(): BridgeDerivation {
|
|
568
|
+
return (candidate) => {
|
|
569
|
+
// `Service` ONLY. `Application` is by convention the SERVER'S OWN actor —
|
|
570
|
+
// Mastodon publishes `https://<host>/actor` as an `Application` named
|
|
571
|
+
// `mastodon.internal` — so accepting it would re-label the instance actor
|
|
572
|
+
// itself onto the upstream network. Caught by an existing guard rather than
|
|
573
|
+
// by review, which is the whole reason that guard is there.
|
|
574
|
+
if (candidate.actorType.trim().toLowerCase() !== 'service') return undefined;
|
|
575
|
+
const handle = candidate.preferredUsername.trim();
|
|
576
|
+
return handle.length > 0 ? handle : undefined;
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function upstreamHandleFromPreferredUsername(markers: readonly RegExp[]): BridgeDerivation {
|
|
581
|
+
return (candidate) => {
|
|
582
|
+
if (!markers.some((marker) => marker.test(candidate.bio))) return undefined;
|
|
583
|
+
const handle = candidate.preferredUsername.trim();
|
|
584
|
+
return handle.length > 0 ? handle : undefined;
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The username a Bluesky handle is stored under, given that the instance domain
|
|
590
|
+
* is ALWAYS `bsky.social`.
|
|
591
|
+
*
|
|
592
|
+
* A Bluesky handle is a whole DNS name identifying the account, not a `local@host`
|
|
593
|
+
* address, so the account is on the Bluesky network however many labels the handle
|
|
594
|
+
* has. Once the instance domain is already `bsky.social`, the `.bsky.social`
|
|
595
|
+
* suffix on a DEFAULT handle is redundant and is dropped — otherwise the handle
|
|
596
|
+
* renders as the doubled `@skylee1.bsky.social@bsky.social`. A CUSTOM domain
|
|
597
|
+
* handle is not a `.bsky.social` handle, so it is kept whole:
|
|
598
|
+
*
|
|
599
|
+
* - `skylee1.bsky.social` → `skylee1`
|
|
600
|
+
* - `gothamist.com` → `gothamist.com`
|
|
601
|
+
* - `mayor.nyc.gov` → `mayor.nyc.gov` (never the bogus `nyc.gov` instance)
|
|
602
|
+
* - `jay.bsky.team` → `jay.bsky.team` (`.bsky.team` is not `.bsky.social`)
|
|
603
|
+
*
|
|
604
|
+
* Exported, and used by BOTH paths a Bluesky account can reach us by — the atproto
|
|
605
|
+
* connector reading it directly, and the Bridgy Fed entry below reading it over
|
|
606
|
+
* ActivityPub. That is the point: the same account arriving by two protocols has
|
|
607
|
+
* to produce the same username or the two rows are two people.
|
|
608
|
+
*
|
|
609
|
+
* `bsky.social` itself is guarded: stripping would leave an empty username, so the
|
|
610
|
+
* whole handle is kept.
|
|
611
|
+
*/
|
|
612
|
+
export function blueskyUsernameFromHandle(handle: string): string {
|
|
613
|
+
const suffix = `.${FEDERATION_NETWORKS.bluesky.domain}`;
|
|
614
|
+
return handle !== FEDERATION_NETWORKS.bluesky.domain && handle.endsWith(suffix)
|
|
615
|
+
? handle.slice(0, -suffix.length)
|
|
616
|
+
: handle;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** A reviewed bridge registry, and the readers an app drives it with. */
|
|
620
|
+
export interface BridgeRelabeller {
|
|
621
|
+
/** The reviewed entry for a host, or `undefined` — most hosts are not bridges. */
|
|
622
|
+
findBridge: (host: string) => FederationBridgeEntry | undefined;
|
|
623
|
+
/**
|
|
624
|
+
* Whether `actorHost` is a reviewed bridge mirroring `networkDomain`. Both
|
|
625
|
+
* halves must match: a bridge vouches ONLY for the one network it mirrors, so
|
|
626
|
+
* being listed is never on its own a licence to claim any domain.
|
|
627
|
+
*/
|
|
628
|
+
vouchesForNetwork: (actorHost: string, networkDomain: string) => boolean;
|
|
629
|
+
/** The {@link DeriveNetworkIdentity} hook an ingest path installs. */
|
|
630
|
+
deriveNetworkIdentity: DeriveNetworkIdentity;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Build the readers for a set of reviewed bridge entries.
|
|
635
|
+
*
|
|
636
|
+
* The entries are a PARAMETER and this package ships none. Deciding that a given
|
|
637
|
+
* operator may be trusted to re-attribute somebody's account is a moderation
|
|
638
|
+
* judgement, not a platform fact — bake one app's list in here and every Oxy app
|
|
639
|
+
* silently inherits it, including consent calls their owners never made. Oxy holds
|
|
640
|
+
* the capability; the app holds the policy, commits it, and answers for it.
|
|
641
|
+
*
|
|
642
|
+
* A blocked host must be refused by the caller's domain policy BEFORE this is
|
|
643
|
+
* consulted: blocking and bridge-trust are opposite decisions about a host and the
|
|
644
|
+
* block wins. No blocklist is accepted here, so this can never be mistaken for the
|
|
645
|
+
* place that decision is made.
|
|
646
|
+
*/
|
|
647
|
+
export function createBridgeRelabeller(
|
|
648
|
+
entries: readonly FederationBridgeEntry[],
|
|
649
|
+
): BridgeRelabeller {
|
|
650
|
+
const byHost = new Map<string, FederationBridgeEntry>(
|
|
651
|
+
entries.map((entry) => [canonicalFederationHost(entry.host), entry]),
|
|
652
|
+
);
|
|
653
|
+
const findBridge = (host: string): FederationBridgeEntry | undefined =>
|
|
654
|
+
byHost.get(canonicalFederationHost(host));
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
findBridge,
|
|
658
|
+
vouchesForNetwork: (actorHost, networkDomain) => {
|
|
659
|
+
const bridge = findBridge(actorHost);
|
|
660
|
+
if (!bridge) return false;
|
|
661
|
+
return canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain);
|
|
662
|
+
},
|
|
663
|
+
deriveNetworkIdentity: (candidate) => {
|
|
664
|
+
const entry = findBridge(candidate.host);
|
|
665
|
+
if (!entry) return undefined;
|
|
666
|
+
// A `pending_dedup` entry is committed, reviewed and deliberately inert:
|
|
667
|
+
// re-labelling it would manufacture visible twins of accounts we already
|
|
668
|
+
// hold under the same derived handle.
|
|
669
|
+
if (entry.relabel !== 'enabled') return undefined;
|
|
670
|
+
|
|
671
|
+
const derived = entry.derive(candidate);
|
|
672
|
+
if (derived === undefined) return undefined;
|
|
673
|
+
|
|
674
|
+
const handle = entry.caseRule === 'lowercase' ? derived.trim().toLowerCase() : derived.trim();
|
|
675
|
+
// An empty handle is the signature of a BROKEN derivation, not of an
|
|
676
|
+
// unusual account — and it is the most destructive possible outcome, since
|
|
677
|
+
// every actor on the domain would collapse onto one identity. We hold
|
|
678
|
+
// federated actors with no `preferredUsername` at all, so this is reachable
|
|
679
|
+
// rather than theoretical. An `@` or `/` would likewise produce an identity
|
|
680
|
+
// that reads as a different account than it addresses.
|
|
681
|
+
if (handle.length === 0 || handle.includes('@') || handle.includes('/')) return undefined;
|
|
682
|
+
|
|
683
|
+
const instanceDomain = canonicalFederationHost(entry.network.domain);
|
|
684
|
+
if (instanceDomain.length === 0) return undefined;
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
federatedUsername: `${handle}@${instanceDomain}`,
|
|
688
|
+
instanceDomain,
|
|
689
|
+
bio: stripBridgeBoilerplate(candidate.bio, entry),
|
|
690
|
+
};
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Strip a bridge's own boilerplate, leaving anything it does not match untouched. */
|
|
696
|
+
export function stripBridgeBoilerplate(bio: string, entry: FederationBridgeEntry): string {
|
|
697
|
+
let result = bio;
|
|
698
|
+
for (const pattern of entry.boilerplate) {
|
|
699
|
+
result = result.replace(pattern, '');
|
|
700
|
+
}
|
|
701
|
+
return result.trim();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* The exact federated username Oxy stores for a pasted upstream profile URL —
|
|
706
|
+
* `https://x.com/NASA` → `nasa@x.com`, `https://bsky.app/profile/alice.bsky.social`
|
|
707
|
+
* → `alice@bsky.social` — or `undefined` when the URL names no known network.
|
|
708
|
+
*
|
|
709
|
+
* This is the SEARCH direction of the same declaration the ingest path reads
|
|
710
|
+
* forwards, and it deliberately routes through `network.storedUsername` rather
|
|
711
|
+
* than reimplementing the normalisation. A search built on a second, parallel
|
|
712
|
+
* rule would work for X (where the rule is just lowercasing) and fail silently
|
|
713
|
+
* for Bluesky (where a default handle's `.bsky.social` suffix is dropped),
|
|
714
|
+
* returning nothing for an account we hold — a result indistinguishable from
|
|
715
|
+
* "we do not have that account", which is why nobody would ever report it.
|
|
716
|
+
*
|
|
717
|
+
* Purely syntactic: it never fetches the URL. Resolving a user-supplied URL by
|
|
718
|
+
* fetching it would be an SSRF surface, and nothing here needs the network.
|
|
719
|
+
*/
|
|
720
|
+
export function federatedUsernameFromUpstreamUrl(
|
|
721
|
+
candidateUrl: string,
|
|
722
|
+
networks: readonly FederationNetwork[] = Object.values(FEDERATION_NETWORKS),
|
|
723
|
+
): string | undefined {
|
|
724
|
+
const parsed = parseUpstreamProfileUrl(candidateUrl, networks);
|
|
725
|
+
if (!parsed) return undefined;
|
|
726
|
+
|
|
727
|
+
const local = parsed.network.storedUsername(parsed.handle);
|
|
728
|
+
if (local.length === 0 || local.includes('@') || local.includes('/')) return undefined;
|
|
729
|
+
|
|
730
|
+
return `${local}@${canonicalFederationHost(parsed.network.domain)}`;
|
|
731
|
+
}
|