@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,99 @@
1
+ "use strict";
2
+ /**
3
+ * @oxy.so/federation — the app-agnostic federation substrate (isomorphic `.` entry).
4
+ *
5
+ * The pluggable network-connector CONTRACT and the normalized, cross-network
6
+ * DTOs every connector produces. An app's content/MTN core never knows about
7
+ * Mastodon (ActivityPub) or Bluesky (atproto); it only ever talks to a
8
+ * {@link NetworkConnector}. This module is that seam: the normalized DTOs every
9
+ * connector produces, the local-event union connectors deliver outbound, and
10
+ * the connector interface itself.
11
+ *
12
+ * IMPORTANT: this entry is intentionally free of Mongoose / Express / React
13
+ * Native so it can be imported from any Oxy app backend (and, in later phases,
14
+ * share the pure HTTP-signature + actor-object surface with browser/isomorphic
15
+ * callers). The runnable Express/Node engine — signed fetch, delivery transport,
16
+ * webfinger/actor/inbox routers, remote-actor resolution — lives under the
17
+ * separate `./node` subpath so it never enters isomorphic bundles.
18
+ *
19
+ * The one piece of app-specific data that flows through the outbound seam — a
20
+ * local post's canonical content — is a TYPE PARAMETER (`TContent`), supplied by
21
+ * the consuming app (Mention passes its `PostContent`). The engine holds no
22
+ * knowledge of any app's post shape.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.localActorTypeForAccountKind = exports.createLocalActorBuilder = exports.createDomainPolicy = exports.extractActorUriFromActivityId = exports.isSameFederationHost = exports.canonicalFederationHost = exports.AP_CONTEXT = exports.readProxyDeclarations = exports.upstreamHandleFromProxyOf = exports.upstreamHandleFromPreferredUsername = exports.upstreamHandleFromAutomatedActor = exports.upstreamHandleFromAlsoKnownAs = exports.upstreamHandleFromProfileField = exports.federatedUsernameFromUpstreamUrl = exports.parseUpstreamProfileUrl = exports.upstreamProfileUrl = exports.stripBridgeBoilerplate = exports.createBridgeRelabeller = exports.blueskyUsernameFromHandle = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = exports.INSTANCE_ACTOR_USERNAME = exports.normalizeActorUsername = exports.createUrlBuilders = exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = exports.verifyHttpSignature = exports.signRequest = void 0;
26
+ /**
27
+ * HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
28
+ * ActivityPub federation shares. Private-key custody is injected; the key never
29
+ * enters this package.
30
+ */
31
+ var httpSignature_1 = require("./httpSignature");
32
+ Object.defineProperty(exports, "signRequest", { enumerable: true, get: function () { return httpSignature_1.signRequest; } });
33
+ Object.defineProperty(exports, "verifyHttpSignature", { enumerable: true, get: function () { return httpSignature_1.verifyHttpSignature; } });
34
+ Object.defineProperty(exports, "HTTP_SIGNATURE_ALGORITHM", { enumerable: true, get: function () { return httpSignature_1.HTTP_SIGNATURE_ALGORITHM; } });
35
+ Object.defineProperty(exports, "DEFAULT_SIGNED_CONTENT_TYPE", { enumerable: true, get: function () { return httpSignature_1.DEFAULT_SIGNED_CONTENT_TYPE; } });
36
+ /**
37
+ * Domain-parameterized ActivityPub URL builders — each app instantiates them once
38
+ * with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
39
+ */
40
+ var urls_1 = require("./urls");
41
+ Object.defineProperty(exports, "createUrlBuilders", { enumerable: true, get: function () { return urls_1.createUrlBuilders; } });
42
+ Object.defineProperty(exports, "normalizeActorUsername", { enumerable: true, get: function () { return urls_1.normalizeActorUsername; } });
43
+ Object.defineProperty(exports, "INSTANCE_ACTOR_USERNAME", { enumerable: true, get: function () { return urls_1.INSTANCE_ACTOR_USERNAME; } });
44
+ /**
45
+ * Network identity: the MECHANISM for re-labelling an account republished by a
46
+ * bridge onto the network it actually came from, plus the network vocabulary and
47
+ * the bidirectional upstream-profile-URL rule.
48
+ *
49
+ * The mechanism is here; the ENTRIES are not, and must not be. Which operators
50
+ * may be trusted to re-attribute somebody's account is a moderation judgement an
51
+ * app commits and answers for — `createBridgeRelabeller` takes them as a
52
+ * parameter so no app inherits another's.
53
+ */
54
+ var networkIdentity_1 = require("./networkIdentity");
55
+ Object.defineProperty(exports, "FEDERATION_NETWORKS", { enumerable: true, get: function () { return networkIdentity_1.FEDERATION_NETWORKS; } });
56
+ Object.defineProperty(exports, "BSKY_NETWORK_DOMAIN", { enumerable: true, get: function () { return networkIdentity_1.BSKY_NETWORK_DOMAIN; } });
57
+ Object.defineProperty(exports, "blueskyUsernameFromHandle", { enumerable: true, get: function () { return networkIdentity_1.blueskyUsernameFromHandle; } });
58
+ Object.defineProperty(exports, "createBridgeRelabeller", { enumerable: true, get: function () { return networkIdentity_1.createBridgeRelabeller; } });
59
+ Object.defineProperty(exports, "stripBridgeBoilerplate", { enumerable: true, get: function () { return networkIdentity_1.stripBridgeBoilerplate; } });
60
+ Object.defineProperty(exports, "upstreamProfileUrl", { enumerable: true, get: function () { return networkIdentity_1.upstreamProfileUrl; } });
61
+ Object.defineProperty(exports, "parseUpstreamProfileUrl", { enumerable: true, get: function () { return networkIdentity_1.parseUpstreamProfileUrl; } });
62
+ Object.defineProperty(exports, "federatedUsernameFromUpstreamUrl", { enumerable: true, get: function () { return networkIdentity_1.federatedUsernameFromUpstreamUrl; } });
63
+ Object.defineProperty(exports, "upstreamHandleFromProfileField", { enumerable: true, get: function () { return networkIdentity_1.upstreamHandleFromProfileField; } });
64
+ Object.defineProperty(exports, "upstreamHandleFromAlsoKnownAs", { enumerable: true, get: function () { return networkIdentity_1.upstreamHandleFromAlsoKnownAs; } });
65
+ Object.defineProperty(exports, "upstreamHandleFromAutomatedActor", { enumerable: true, get: function () { return networkIdentity_1.upstreamHandleFromAutomatedActor; } });
66
+ Object.defineProperty(exports, "upstreamHandleFromPreferredUsername", { enumerable: true, get: function () { return networkIdentity_1.upstreamHandleFromPreferredUsername; } });
67
+ Object.defineProperty(exports, "upstreamHandleFromProxyOf", { enumerable: true, get: function () { return networkIdentity_1.upstreamHandleFromProxyOf; } });
68
+ Object.defineProperty(exports, "readProxyDeclarations", { enumerable: true, get: function () { return networkIdentity_1.readProxyDeclarations; } });
69
+ /**
70
+ * The shared JSON-LD `@context` (load-bearing term declarations) and the
71
+ * ActivityPub URI helpers (actor-uri extraction + the per-instance domain policy:
72
+ * blocked-domain check + local-post-id extraction).
73
+ *
74
+ * `canonicalFederationHost` / `isSameFederationHost` are exported because the
75
+ * domain policy is not the only thing that has to decide whether two spellings
76
+ * are the same host: a moderation blocklist, a transparency page and a content
77
+ * purge all ask the same question about the same hosts, and any of them keeping
78
+ * its own copy of the rule is a second opinion waiting to diverge from the one
79
+ * the engine enforces. They are the very functions {@link createDomainPolicy} is
80
+ * built from — not a parallel implementation that agrees today.
81
+ */
82
+ var apContext_1 = require("./apContext");
83
+ Object.defineProperty(exports, "AP_CONTEXT", { enumerable: true, get: function () { return apContext_1.AP_CONTEXT; } });
84
+ var apUri_1 = require("./apUri");
85
+ Object.defineProperty(exports, "canonicalFederationHost", { enumerable: true, get: function () { return apUri_1.canonicalFederationHost; } });
86
+ Object.defineProperty(exports, "isSameFederationHost", { enumerable: true, get: function () { return apUri_1.isSameFederationHost; } });
87
+ Object.defineProperty(exports, "extractActorUriFromActivityId", { enumerable: true, get: function () { return apUri_1.extractActorUriFromActivityId; } });
88
+ Object.defineProperty(exports, "createDomainPolicy", { enumerable: true, get: function () { return apUri_1.createDomainPolicy; } });
89
+ /**
90
+ * The single builder of a LOCAL user's ActivityPub actor document —
91
+ * byte-identical across apps, with media resolution injected. The actor `type`
92
+ * follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
93
+ */
94
+ var actorObject_1 = require("./actorObject");
95
+ Object.defineProperty(exports, "createLocalActorBuilder", { enumerable: true, get: function () { return actorObject_1.createLocalActorBuilder; } });
96
+ Object.defineProperty(exports, "localActorTypeForAccountKind", { enumerable: true, get: function () { return actorObject_1.localActorTypeForAccountKind; } });
97
+ Object.defineProperty(exports, "isApActorType", { enumerable: true, get: function () { return actorObject_1.isApActorType; } });
98
+ Object.defineProperty(exports, "AP_ACTOR_TYPES", { enumerable: true, get: function () { return actorObject_1.AP_ACTOR_TYPES; } });
99
+ Object.defineProperty(exports, "LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND", { enumerable: true, get: function () { return actorObject_1.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND; } });
@@ -0,0 +1,487 @@
1
+ "use strict";
2
+ /**
3
+ * WHICH HOSTS REPUBLISH ANOTHER NETWORK'S ACCOUNTS, AND HOW TO READ THE REAL
4
+ * IDENTITY BACK OUT OF THEM.
5
+ *
6
+ * A BRIDGE is a fediverse host that mirrors accounts from somewhere else. The
7
+ * account it publishes as `@WIRED@mastox.eu` is not a person on mastox.eu — it is
8
+ * WIRED, on X, copied. Naming that account after the bridge tells a reader
9
+ * nothing they can act on: the hostname is an implementation detail of how the
10
+ * post reached us, and the thing they actually want to know is which account on
11
+ * which network wrote it. So an actor from a listed bridge is stored and rendered
12
+ * under the NETWORK it came from — `@wired@x.com` — exactly as an atproto actor
13
+ * with a custom-domain handle is stored under `bsky.social` rather than under the
14
+ * domain the handle happens to spell.
15
+ *
16
+ * WHY THE MECHANISM LIVES HERE BUT THE ENTRIES DO NOT
17
+ *
18
+ * Two different questions must stay separate. An app's connector DERIVES the
19
+ * identity at ingest (`createBridgeRelabeller(entries)` with entries the app
20
+ * commits and answers for), and oxy-api's `PUT /users/resolve` DECIDES
21
+ * WHETHER TO BELIEVE IT — that endpoint binds an actor URI's hostname to the
22
+ * domain the caller asserts, precisely so a service cannot claim to vouch for
23
+ * a user on a host it does not own. A bridged identity is the one case where
24
+ * those legitimately differ. The shared package ships the derivation machinery
25
+ * and network vocabulary; each side keeps its own reviewed list and they fail
26
+ * CLOSED in both directions — an app that derives for a bridge the API does
27
+ * not trust simply has its resolve refused, and a host the API trusts that no
28
+ * app derives for does nothing at all.
29
+ *
30
+ * A WRONG ENTRY HERE MISATTRIBUTES SOMEBODY'S WRITING
31
+ *
32
+ * That is a heavier failure than the blocklist's. A wrong block loses content
33
+ * and somebody complains; a wrong bridge entry silently publishes one person's
34
+ * posts under another person's name, on a network they may not even use. So
35
+ * every entry records what was actually VERIFIED against a live actor
36
+ * ({@link FederationBridgeEntry.evidence}) separately from what is merely
37
+ * ASSUMED ({@link FederationBridgeEntry.assumption}), and every entry an app
38
+ * ships should carry a stored fixture and a test that fails if its rule stops
39
+ * round-tripping. Derivation is per-ACTOR and fails closed: an actor that does
40
+ * not satisfy its bridge's rule keeps the bridge hostname, because a bridge's
41
+ * own admin and service accounts are real accounts on that host and relabelling
42
+ * them would invent an upstream person who does not exist.
43
+ *
44
+ * THIS IS NOT THE BLOCKLIST, AND MUST NEVER BE MERGED WITH IT
45
+ *
46
+ * Blocking and bridge-trust are opposite decisions about a host, and the
47
+ * blocklist wins: a blocked host is refused before any actor from it is ever
48
+ * built, so no relabel can resurrect it. Keeping them in separate structures
49
+ * means neither can be edited into the other by accident.
50
+ */
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = void 0;
53
+ exports.upstreamProfileUrl = upstreamProfileUrl;
54
+ exports.parseUpstreamProfileUrl = parseUpstreamProfileUrl;
55
+ exports.readProxyDeclarations = readProxyDeclarations;
56
+ exports.upstreamHandleFromProfileField = upstreamHandleFromProfileField;
57
+ exports.upstreamHandleFromAlsoKnownAs = upstreamHandleFromAlsoKnownAs;
58
+ exports.upstreamHandleFromProxyOf = upstreamHandleFromProxyOf;
59
+ exports.upstreamHandleFromAutomatedActor = upstreamHandleFromAutomatedActor;
60
+ exports.upstreamHandleFromPreferredUsername = upstreamHandleFromPreferredUsername;
61
+ exports.blueskyUsernameFromHandle = blueskyUsernameFromHandle;
62
+ exports.createBridgeRelabeller = createBridgeRelabeller;
63
+ exports.stripBridgeBoilerplate = stripBridgeBoilerplate;
64
+ exports.federatedUsernameFromUpstreamUrl = federatedUsernameFromUpstreamUrl;
65
+ const apUri_1 = require("./apUri");
66
+ /**
67
+ * The networks Oxy re-labels accounts onto.
68
+ *
69
+ * Bluesky is here for a reason beyond bridging: it is the network the atproto
70
+ * connector ingests DIRECTLY, and its domain used to be a constant private to
71
+ * that connector. Both readers now take it from here, so a Bluesky account
72
+ * reaching us over atproto and the same account reaching us over ActivityPub
73
+ * through Bridgy Fed cannot end up under two different domains — which is what
74
+ * would happen if the two paths each named the network themselves.
75
+ */
76
+ exports.FEDERATION_NETWORKS = {
77
+ x: {
78
+ id: 'x',
79
+ name: 'X',
80
+ domain: 'x.com',
81
+ profileHosts: ['x.com', 'twitter.com', 'mobile.twitter.com', 'mobile.x.com'],
82
+ profilePathPrefix: [],
83
+ storedUsername: (handle) => handle.trim().toLowerCase(),
84
+ },
85
+ instagram: {
86
+ id: 'instagram',
87
+ name: 'Instagram',
88
+ domain: 'instagram.com',
89
+ profileHosts: ['instagram.com'],
90
+ profilePathPrefix: [],
91
+ storedUsername: (handle) => handle.trim().toLowerCase(),
92
+ },
93
+ bluesky: {
94
+ id: 'bluesky',
95
+ name: 'Bluesky',
96
+ domain: 'bsky.social',
97
+ profileHosts: ['bsky.app'],
98
+ profilePathPrefix: ['profile'],
99
+ storedUsername: (handle) => blueskyUsernameFromHandle(handle.trim()),
100
+ },
101
+ };
102
+ /**
103
+ * The upstream profile URL for a handle on a network.
104
+ *
105
+ * Deliberately the SAME declaration {@link parseUpstreamProfileUrl} reads
106
+ * backwards. Rendering a link and recognising a pasted one are the same fact
107
+ * stated in two directions, and holding them as two independent tables is how
108
+ * they drift — with the failure landing on the parsing side, where a search that
109
+ * silently finds nothing is indistinguishable from "we do not have that account"
110
+ * and so nobody ever reports it.
111
+ */
112
+ function upstreamProfileUrl(network, handle) {
113
+ const path = [...network.profilePathPrefix, encodeURIComponent(handle)].join('/');
114
+ return `https://${network.profileHosts[0]}/${path}`;
115
+ }
116
+ /**
117
+ * The network and handle a pasted upstream profile URL names, or `undefined` when
118
+ * it is not one.
119
+ *
120
+ * Query strings and fragments are dropped (a pasted URL usually carries tracking
121
+ * parameters) and a trailing slash is tolerated. Purely syntactic: it never
122
+ * fetches the URL — resolving a user-supplied URL by fetching it would be an SSRF
123
+ * surface, and there is nothing here that needs the network.
124
+ */
125
+ function parseUpstreamProfileUrl(candidateUrl, networks = Object.values(exports.FEDERATION_NETWORKS)) {
126
+ let url;
127
+ try {
128
+ url = new URL(candidateUrl.trim());
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
134
+ return undefined;
135
+ const host = (0, apUri_1.canonicalFederationHost)(url.hostname);
136
+ for (const network of networks) {
137
+ if (!network.profileHosts.some((allowed) => (0, apUri_1.canonicalFederationHost)(allowed) === host))
138
+ continue;
139
+ const handle = profileUrlHandle(url.href, network.profileHosts, network.profilePathPrefix);
140
+ if (handle !== undefined && handle.length > 0)
141
+ return { network, handle };
142
+ }
143
+ return undefined;
144
+ }
145
+ /** The Bluesky network's canonical identity domain — see {@link FEDERATION_NETWORKS}. */
146
+ exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS.bluesky.domain;
147
+ /**
148
+ * Parse an actor's `proxyOf` into well-formed declarations, dropping anything
149
+ * malformed. Pure; accepts `unknown` because it reads an untrusted document.
150
+ *
151
+ * `authoritative` DEFAULTS TO FALSE when absent. FEP-fffd leaves it optional, and
152
+ * the conservative reading is the only safe one here: the flag is what
153
+ * distinguishes "this actor IS that upstream account" from "this actor is one
154
+ * copy of it", and only the former could ever justify moving an identity.
155
+ */
156
+ function readProxyDeclarations(value) {
157
+ if (!Array.isArray(value))
158
+ return [];
159
+ const declarations = [];
160
+ for (const raw of value) {
161
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
162
+ continue;
163
+ const entry = raw;
164
+ const protocol = typeof entry.protocol === 'string' ? entry.protocol.trim() : '';
165
+ const proxied = typeof entry.proxied === 'string' ? entry.proxied.trim() : '';
166
+ if (protocol.length === 0 || proxied.length === 0)
167
+ continue;
168
+ declarations.push({ protocol, proxied, authoritative: entry.authoritative === true });
169
+ }
170
+ return declarations;
171
+ }
172
+ /**
173
+ * The handle a profile URL addresses: the single path segment that follows the
174
+ * network's fixed profile prefix (`x.com/<handle>` has none, `bsky.app` uses
175
+ * `profile/`), on one of the network's own hosts.
176
+ *
177
+ * Exact — one segment after the prefix and nothing more — so a link to some other
178
+ * page on the same host (`x.com/i/status/123`) yields nothing rather than a
179
+ * plausible-looking wrong handle.
180
+ */
181
+ function profileUrlHandle(href, allowedHosts, pathPrefix) {
182
+ let url;
183
+ try {
184
+ url = new URL(href);
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
190
+ return undefined;
191
+ const host = (0, apUri_1.canonicalFederationHost)(url.hostname);
192
+ if (!allowedHosts.some((allowed) => (0, apUri_1.canonicalFederationHost)(allowed) === host))
193
+ return undefined;
194
+ const segments = url.pathname.split('/').filter((s) => s.length > 0);
195
+ if (segments.length !== pathPrefix.length + 1)
196
+ return undefined;
197
+ for (let i = 0; i < pathPrefix.length; i += 1) {
198
+ if (segments[i].toLowerCase() !== pathPrefix[i])
199
+ return undefined;
200
+ }
201
+ return decodeURIComponent(segments[pathPrefix.length]);
202
+ }
203
+ /** Every `href="…"` in a sanitized field value, in document order. */
204
+ function fieldHrefs(value) {
205
+ const hrefs = [];
206
+ const pattern = /href="([^"]*)"/gi;
207
+ let match = pattern.exec(value);
208
+ while (match !== null) {
209
+ hrefs.push(match[1]);
210
+ match = pattern.exec(value);
211
+ }
212
+ return hrefs;
213
+ }
214
+ /**
215
+ * Read the upstream handle out of a named profile field that links to the
216
+ * upstream profile — the STRONGEST rule available, because the bridge is
217
+ * publishing a machine-readable assertion of which account this mirrors rather
218
+ * than leaving us to infer it from the username.
219
+ */
220
+ function upstreamHandleFromProfileField(options) {
221
+ const wanted = options.fieldName.toLowerCase();
222
+ const prefix = options.pathPrefix ?? [];
223
+ return (candidate) => {
224
+ for (const field of candidate.fields) {
225
+ if (field.name.trim().toLowerCase() !== wanted)
226
+ continue;
227
+ for (const href of fieldHrefs(field.value)) {
228
+ const handle = profileUrlHandle(href, options.hosts, prefix);
229
+ if (handle !== undefined && handle.length > 0)
230
+ return handle;
231
+ }
232
+ }
233
+ return undefined;
234
+ };
235
+ }
236
+ /**
237
+ * Read the upstream handle out of `alsoKnownAs` profile URLs — the shape where an
238
+ * actor publishes a profile link there rather than in a named profile field.
239
+ *
240
+ * ⚠ UNMATCHED BY ANY ACTOR WE ACTUALLY HOLD. On all three Bridgy Fed actors
241
+ * captured from production, `alsoKnownAs` contains ONLY the atproto DID
242
+ * (`["did:plc:…"]`) and no `bsky.app` URL, so this returns `undefined` for the
243
+ * entire real corpus; the shipped Bridgy entry reads the `Web site` profile
244
+ * field, which every one of them does carry. Written against the documented
245
+ * shape rather than an observed one — so verify against a live actor before
246
+ * building on it, and do not read a green test suite as evidence that it fires.
247
+ *
248
+ * `alsoKnownAs` is also NOT a generic upstream backlink. It is one on Bridgy,
249
+ * but on the stock-Mastodon mirror farms it is a Mastodon MIGRATION pointer at a
250
+ * sibling farm domain — following it there would attribute an account to
251
+ * whatever that pointer happens to name. Only use this where a reviewed entry
252
+ * states that the bridge publishes an upstream link in that field.
253
+ */
254
+ function upstreamHandleFromAlsoKnownAs(options) {
255
+ const prefix = options.pathPrefix ?? [];
256
+ return (candidate) => {
257
+ for (const href of candidate.alsoKnownAs) {
258
+ const handle = profileUrlHandle(href, options.hosts, prefix);
259
+ if (handle !== undefined && handle.length > 0)
260
+ return handle;
261
+ }
262
+ return undefined;
263
+ };
264
+ }
265
+ /**
266
+ * Read the upstream identifier out of an actor's FEP-fffd `proxyOf` declaration.
267
+ *
268
+ * THIS IS A STRATEGY A REVIEWED ENTRY OPTS INTO — NOT A REGISTRY-FREE LANE, AND
269
+ * THE DIFFERENCE IS THE WHOLE SECURITY ARGUMENT.
270
+ *
271
+ * `proxyOf` is attractive precisely because it is self-describing: the actor
272
+ * states what it proxies, in a ratified format, with no list to maintain. That
273
+ * is exactly why it cannot be believed on its own. It is a claim made by an
274
+ * UNTRUSTED REMOTE ACTOR about its own identity, and every field in it is
275
+ * attacker-controlled. Honouring it wherever it appears would mean any actor on
276
+ * any instance could publish
277
+ *
278
+ * "proxyOf": [{ "protocol": "…", "proxied": "elonmusk", "authoritative": true }]
279
+ *
280
+ * and be stored, rendered and searchable as that person on that network. The
281
+ * reviewed bridge list is not bureaucracy around this; it IS the thing that
282
+ * makes a re-attribution believable, because we checked who runs the host.
283
+ *
284
+ * Inside an entry the claim is safe for the same reason the entry's other rules
285
+ * are: we already decided we believe this operator about who it mirrors. So the
286
+ * strategy exists, it is tested, and it is reachable only from a host somebody
287
+ * reviewed.
288
+ *
289
+ * `authoritative` must be true — a non-authoritative proxy says the actor is one
290
+ * copy of the upstream object, not that it stands in for it.
291
+ *
292
+ * NOTHING WE INGEST USES THIS YET. The only actors in our corpus that publish
293
+ * `proxyOf` are the two Nostr bridges, and Nostr identities are npubs with no
294
+ * `@handle@domain` form to re-label onto, so no shipped entry names it. It is
295
+ * here so a bridge that adopts FEP-fffd needs an entry rather than new code —
296
+ * do not read its passing tests as evidence that it fires in production.
297
+ */
298
+ function upstreamHandleFromProxyOf(options) {
299
+ const accepted = new Set(options.protocols.map((protocol) => protocol.trim().toLowerCase()));
300
+ const toHandle = options.handleFromProxied ?? ((proxied) => proxied);
301
+ return (candidate) => {
302
+ for (const declaration of candidate.proxyOf) {
303
+ if (!declaration.authoritative)
304
+ continue;
305
+ if (!accepted.has(declaration.protocol.trim().toLowerCase()))
306
+ continue;
307
+ const handle = toHandle(declaration.proxied);
308
+ if (handle !== undefined && handle.length > 0)
309
+ return handle;
310
+ }
311
+ return undefined;
312
+ };
313
+ }
314
+ /**
315
+ * Use the actor's own `preferredUsername` as the upstream handle, but ONLY for an
316
+ * actor that carries one of the bridge's mirror notices.
317
+ *
318
+ * The notice is what distinguishes a mirrored account from a real account on the
319
+ * bridge host: the operator's own admin account lives there too and is not a
320
+ * mirror of anything. Without the marker requirement this rule would relabel that
321
+ * person onto a network they may not even be on.
322
+ */
323
+ /**
324
+ * A mirror identified by the actor DECLARING ITSELF AUTOMATED, with the handle
325
+ * read from `preferredUsername`.
326
+ *
327
+ * For a bridge that runs stock server software there is nothing to fingerprint:
328
+ * somebody points a mirror bot at an ordinary instance and the result is
329
+ * indistinguishable from any other server. The tempting fallback is to match the
330
+ * per-account notice such a bridge writes into each bio — and that fails, because
331
+ * a notice is free text with LANGUAGES. One deployment served the same sentence
332
+ * in English, French and Spanish; an entry listing two of them silently left
333
+ * every account of the third under the bridge's own hostname, with the notice
334
+ * still in its bio, looking exactly like an ordinary account.
335
+ *
336
+ * `type` is the same claim without the prose. ActivityPub already distinguishes
337
+ * an automated actor (`Service`/`Application`) from a `Person`, every mirror is
338
+ * published as one, and the operator's own account is not — so the bridge's
339
+ * machine-readable declaration replaces a guess about wording. It is still a
340
+ * per-ACTOR proof, which is what keeps a human on that host from being
341
+ * re-attributed to another network.
342
+ *
343
+ * NOT a general "this actor is a bot" rule: it is only ever consulted for a host
344
+ * already reviewed into a bridge policy. Plenty of ordinary fediverse accounts
345
+ * are `Service`, and none of them are on a listed bridge.
346
+ */
347
+ function upstreamHandleFromAutomatedActor() {
348
+ return (candidate) => {
349
+ // `Service` ONLY. `Application` is by convention the SERVER'S OWN actor —
350
+ // Mastodon publishes `https://<host>/actor` as an `Application` named
351
+ // `mastodon.internal` — so accepting it would re-label the instance actor
352
+ // itself onto the upstream network. Caught by an existing guard rather than
353
+ // by review, which is the whole reason that guard is there.
354
+ if (candidate.actorType.trim().toLowerCase() !== 'service')
355
+ return undefined;
356
+ const handle = candidate.preferredUsername.trim();
357
+ return handle.length > 0 ? handle : undefined;
358
+ };
359
+ }
360
+ function upstreamHandleFromPreferredUsername(markers) {
361
+ return (candidate) => {
362
+ if (!markers.some((marker) => marker.test(candidate.bio)))
363
+ return undefined;
364
+ const handle = candidate.preferredUsername.trim();
365
+ return handle.length > 0 ? handle : undefined;
366
+ };
367
+ }
368
+ /**
369
+ * The username a Bluesky handle is stored under, given that the instance domain
370
+ * is ALWAYS `bsky.social`.
371
+ *
372
+ * A Bluesky handle is a whole DNS name identifying the account, not a `local@host`
373
+ * address, so the account is on the Bluesky network however many labels the handle
374
+ * has. Once the instance domain is already `bsky.social`, the `.bsky.social`
375
+ * suffix on a DEFAULT handle is redundant and is dropped — otherwise the handle
376
+ * renders as the doubled `@skylee1.bsky.social@bsky.social`. A CUSTOM domain
377
+ * handle is not a `.bsky.social` handle, so it is kept whole:
378
+ *
379
+ * - `skylee1.bsky.social` → `skylee1`
380
+ * - `gothamist.com` → `gothamist.com`
381
+ * - `mayor.nyc.gov` → `mayor.nyc.gov` (never the bogus `nyc.gov` instance)
382
+ * - `jay.bsky.team` → `jay.bsky.team` (`.bsky.team` is not `.bsky.social`)
383
+ *
384
+ * Exported, and used by BOTH paths a Bluesky account can reach us by — the atproto
385
+ * connector reading it directly, and the Bridgy Fed entry below reading it over
386
+ * ActivityPub. That is the point: the same account arriving by two protocols has
387
+ * to produce the same username or the two rows are two people.
388
+ *
389
+ * `bsky.social` itself is guarded: stripping would leave an empty username, so the
390
+ * whole handle is kept.
391
+ */
392
+ function blueskyUsernameFromHandle(handle) {
393
+ const suffix = `.${exports.FEDERATION_NETWORKS.bluesky.domain}`;
394
+ return handle !== exports.FEDERATION_NETWORKS.bluesky.domain && handle.endsWith(suffix)
395
+ ? handle.slice(0, -suffix.length)
396
+ : handle;
397
+ }
398
+ /**
399
+ * Build the readers for a set of reviewed bridge entries.
400
+ *
401
+ * The entries are a PARAMETER and this package ships none. Deciding that a given
402
+ * operator may be trusted to re-attribute somebody's account is a moderation
403
+ * judgement, not a platform fact — bake one app's list in here and every Oxy app
404
+ * silently inherits it, including consent calls their owners never made. Oxy holds
405
+ * the capability; the app holds the policy, commits it, and answers for it.
406
+ *
407
+ * A blocked host must be refused by the caller's domain policy BEFORE this is
408
+ * consulted: blocking and bridge-trust are opposite decisions about a host and the
409
+ * block wins. No blocklist is accepted here, so this can never be mistaken for the
410
+ * place that decision is made.
411
+ */
412
+ function createBridgeRelabeller(entries) {
413
+ const byHost = new Map(entries.map((entry) => [(0, apUri_1.canonicalFederationHost)(entry.host), entry]));
414
+ const findBridge = (host) => byHost.get((0, apUri_1.canonicalFederationHost)(host));
415
+ return {
416
+ findBridge,
417
+ vouchesForNetwork: (actorHost, networkDomain) => {
418
+ const bridge = findBridge(actorHost);
419
+ if (!bridge)
420
+ return false;
421
+ return (0, apUri_1.canonicalFederationHost)(bridge.network.domain) === (0, apUri_1.canonicalFederationHost)(networkDomain);
422
+ },
423
+ deriveNetworkIdentity: (candidate) => {
424
+ const entry = findBridge(candidate.host);
425
+ if (!entry)
426
+ return undefined;
427
+ // A `pending_dedup` entry is committed, reviewed and deliberately inert:
428
+ // re-labelling it would manufacture visible twins of accounts we already
429
+ // hold under the same derived handle.
430
+ if (entry.relabel !== 'enabled')
431
+ return undefined;
432
+ const derived = entry.derive(candidate);
433
+ if (derived === undefined)
434
+ return undefined;
435
+ const handle = entry.caseRule === 'lowercase' ? derived.trim().toLowerCase() : derived.trim();
436
+ // An empty handle is the signature of a BROKEN derivation, not of an
437
+ // unusual account — and it is the most destructive possible outcome, since
438
+ // every actor on the domain would collapse onto one identity. We hold
439
+ // federated actors with no `preferredUsername` at all, so this is reachable
440
+ // rather than theoretical. An `@` or `/` would likewise produce an identity
441
+ // that reads as a different account than it addresses.
442
+ if (handle.length === 0 || handle.includes('@') || handle.includes('/'))
443
+ return undefined;
444
+ const instanceDomain = (0, apUri_1.canonicalFederationHost)(entry.network.domain);
445
+ if (instanceDomain.length === 0)
446
+ return undefined;
447
+ return {
448
+ federatedUsername: `${handle}@${instanceDomain}`,
449
+ instanceDomain,
450
+ bio: stripBridgeBoilerplate(candidate.bio, entry),
451
+ };
452
+ },
453
+ };
454
+ }
455
+ /** Strip a bridge's own boilerplate, leaving anything it does not match untouched. */
456
+ function stripBridgeBoilerplate(bio, entry) {
457
+ let result = bio;
458
+ for (const pattern of entry.boilerplate) {
459
+ result = result.replace(pattern, '');
460
+ }
461
+ return result.trim();
462
+ }
463
+ /**
464
+ * The exact federated username Oxy stores for a pasted upstream profile URL —
465
+ * `https://x.com/NASA` → `nasa@x.com`, `https://bsky.app/profile/alice.bsky.social`
466
+ * → `alice@bsky.social` — or `undefined` when the URL names no known network.
467
+ *
468
+ * This is the SEARCH direction of the same declaration the ingest path reads
469
+ * forwards, and it deliberately routes through `network.storedUsername` rather
470
+ * than reimplementing the normalisation. A search built on a second, parallel
471
+ * rule would work for X (where the rule is just lowercasing) and fail silently
472
+ * for Bluesky (where a default handle's `.bsky.social` suffix is dropped),
473
+ * returning nothing for an account we hold — a result indistinguishable from
474
+ * "we do not have that account", which is why nobody would ever report it.
475
+ *
476
+ * Purely syntactic: it never fetches the URL. Resolving a user-supplied URL by
477
+ * fetching it would be an SSRF surface, and nothing here needs the network.
478
+ */
479
+ function federatedUsernameFromUpstreamUrl(candidateUrl, networks = Object.values(exports.FEDERATION_NETWORKS)) {
480
+ const parsed = parseUpstreamProfileUrl(candidateUrl, networks);
481
+ if (!parsed)
482
+ return undefined;
483
+ const local = parsed.network.storedUsername(parsed.handle);
484
+ if (local.length === 0 || local.includes('@') || local.includes('/'))
485
+ return undefined;
486
+ return `${local}@${(0, apUri_1.canonicalFederationHost)(parsed.network.domain)}`;
487
+ }