@oxy.so/federation 2.11.0 → 2.13.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/dist/cjs/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * knowledge of any app's post shape.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.groupFollowersCollection = exports.groupOutboxCollection = exports.isGroupActorKind = exports.GROUP_OUTBOX_SIZE = exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = 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;
25
+ exports.groupFollowersCollection = exports.groupOutboxCollection = exports.isGroupActorKind = exports.GROUP_OUTBOX_SIZE = exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = exports.isApActorType = exports.accountKindForActor = exports.localActorTypeForAccountKind = exports.normalizeAlsoKnownAs = 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.atprotoIdentityHandle = exports.isUnresolvedAtprotoHandle = exports.UNRESOLVED_ATPROTO_HANDLE = exports.blueskyHandleFromUsername = 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
26
  /**
27
27
  * HTTP Signatures (draft-cavage) — the pure sign/verify crypto every Oxy app's
28
28
  * ActivityPub federation shares. Private-key custody is injected; the key never
@@ -55,6 +55,10 @@ var networkIdentity_1 = require("./networkIdentity");
55
55
  Object.defineProperty(exports, "FEDERATION_NETWORKS", { enumerable: true, get: function () { return networkIdentity_1.FEDERATION_NETWORKS; } });
56
56
  Object.defineProperty(exports, "BSKY_NETWORK_DOMAIN", { enumerable: true, get: function () { return networkIdentity_1.BSKY_NETWORK_DOMAIN; } });
57
57
  Object.defineProperty(exports, "blueskyUsernameFromHandle", { enumerable: true, get: function () { return networkIdentity_1.blueskyUsernameFromHandle; } });
58
+ Object.defineProperty(exports, "blueskyHandleFromUsername", { enumerable: true, get: function () { return networkIdentity_1.blueskyHandleFromUsername; } });
59
+ Object.defineProperty(exports, "UNRESOLVED_ATPROTO_HANDLE", { enumerable: true, get: function () { return networkIdentity_1.UNRESOLVED_ATPROTO_HANDLE; } });
60
+ Object.defineProperty(exports, "isUnresolvedAtprotoHandle", { enumerable: true, get: function () { return networkIdentity_1.isUnresolvedAtprotoHandle; } });
61
+ Object.defineProperty(exports, "atprotoIdentityHandle", { enumerable: true, get: function () { return networkIdentity_1.atprotoIdentityHandle; } });
58
62
  Object.defineProperty(exports, "createBridgeRelabeller", { enumerable: true, get: function () { return networkIdentity_1.createBridgeRelabeller; } });
59
63
  Object.defineProperty(exports, "stripBridgeBoilerplate", { enumerable: true, get: function () { return networkIdentity_1.stripBridgeBoilerplate; } });
60
64
  Object.defineProperty(exports, "upstreamProfileUrl", { enumerable: true, get: function () { return networkIdentity_1.upstreamProfileUrl; } });
@@ -49,7 +49,7 @@
49
49
  * means neither can be edited into the other by accident.
50
50
  */
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
- exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = void 0;
52
+ exports.UNRESOLVED_ATPROTO_HANDLE = exports.BSKY_NETWORK_DOMAIN = exports.FEDERATION_NETWORKS = void 0;
53
53
  exports.upstreamProfileUrl = upstreamProfileUrl;
54
54
  exports.parseUpstreamProfileUrl = parseUpstreamProfileUrl;
55
55
  exports.readProxyDeclarations = readProxyDeclarations;
@@ -59,6 +59,9 @@ exports.upstreamHandleFromProxyOf = upstreamHandleFromProxyOf;
59
59
  exports.upstreamHandleFromAutomatedActor = upstreamHandleFromAutomatedActor;
60
60
  exports.upstreamHandleFromPreferredUsername = upstreamHandleFromPreferredUsername;
61
61
  exports.blueskyUsernameFromHandle = blueskyUsernameFromHandle;
62
+ exports.blueskyHandleFromUsername = blueskyHandleFromUsername;
63
+ exports.isUnresolvedAtprotoHandle = isUnresolvedAtprotoHandle;
64
+ exports.atprotoIdentityHandle = atprotoIdentityHandle;
62
65
  exports.createBridgeRelabeller = createBridgeRelabeller;
63
66
  exports.stripBridgeBoilerplate = stripBridgeBoilerplate;
64
67
  exports.federatedUsernameFromUpstreamUrl = federatedUsernameFromUpstreamUrl;
@@ -424,6 +427,44 @@ function blueskyUsernameFromHandle(handle) {
424
427
  ? handle.slice(0, -suffix.length)
425
428
  : handle;
426
429
  }
430
+ /**
431
+ * The inverse of {@link blueskyUsernameFromHandle}: the atproto handle a stored
432
+ * Bluesky username names. A username without a dot was a default
433
+ * `.bsky.social` handle; one with a dot is a custom domain, kept whole.
434
+ */
435
+ function blueskyHandleFromUsername(username) {
436
+ return username.includes('.') ? username : `${username}.${exports.FEDERATION_NETWORKS.bluesky.domain}`;
437
+ }
438
+ /**
439
+ * The literal handle a Bluesky AppView serves when a handle's bidirectional
440
+ * DNS/DID verification FAILS.
441
+ *
442
+ * It is an ERROR STRING, not an identity: every account whose handle cannot be
443
+ * verified gets the same one, so it is the single value in the atproto namespace
444
+ * guaranteed NOT to be unique. Keying an account on it collapses every such
445
+ * account onto one identity. Use {@link atprotoIdentityHandle}.
446
+ */
447
+ exports.UNRESOLVED_ATPROTO_HANDLE = 'handle.invalid';
448
+ /**
449
+ * True when a handle is the unresolved-handle sentinel rather than a real one.
450
+ * Compared case-insensitively on the trimmed value: a handle is a DNS name.
451
+ */
452
+ function isUnresolvedAtprotoHandle(handle) {
453
+ return handle.trim().toLowerCase() === exports.UNRESOLVED_ATPROTO_HANDLE;
454
+ }
455
+ /**
456
+ * The handle an atproto account is IDENTIFIED by: its real handle, or its DID
457
+ * when the AppView could not verify one ({@link UNRESOLVED_ATPROTO_HANDLE}).
458
+ *
459
+ * The DID is the identifier atproto guarantees stable and unique. It contains a
460
+ * `:`, which no DNS handle may, so the substituted identity never collides with
461
+ * a real one, and `did:plc:…@bsky.social` splits on its `@` like any other
462
+ * acct. Nothing is stored under the sentinel, so the next refresh after the
463
+ * account fixes its DNS re-derives the real handle through the ordinary path.
464
+ */
465
+ function atprotoIdentityHandle(handle, did) {
466
+ return isUnresolvedAtprotoHandle(handle) ? did : handle;
467
+ }
427
468
  /**
428
469
  * Build the readers for a set of reviewed bridge entries.
429
470
  *
@@ -344,6 +344,9 @@ class ActorResolver {
344
344
  const alsoKnownAs = Array.isArray(actor.alsoKnownAs)
345
345
  ? actor.alsoKnownAs.filter((v) => typeof v === 'string')
346
346
  : undefined;
347
+ // A move target is an actor URL; anything else is not one.
348
+ const movedToValue = asString(actor.movedTo);
349
+ const movedTo = movedToValue && /^https?:\/\//.test(movedToValue) ? movedToValue : undefined;
347
350
  // The IDENTITY the actor is stored under in Oxy, which is not necessarily the
348
351
  // host it was fetched from — see `DeriveNetworkIdentity`. Everything below
349
352
  // that addresses the actor over the PROTOCOL (`acct`, `uri`, `domain`) is
@@ -391,12 +394,14 @@ class ActorResolver {
391
394
  type: asString(actor.type) || 'Person',
392
395
  manuallyApprovesFollowers: actor.manuallyApprovesFollowers === true,
393
396
  discoverable: actor.discoverable !== false,
397
+ ...(typeof actor.indexable === 'boolean' && { indexable: actor.indexable }),
394
398
  memorial: actor.memorial === true,
395
399
  suspended: actor.suspended === true,
396
400
  fields,
397
401
  featuredUrl: asString(actor.featured) || undefined,
398
402
  featuredTagsUrl: asString(actor.featuredTags) || undefined,
399
403
  alsoKnownAs,
404
+ ...(movedTo && { movedTo }),
400
405
  networkAcct: networkIdentity?.federatedUsername,
401
406
  remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
402
407
  // Omitted, not written as `undefined`, when this refresh could not tell:
package/dist/esm/index.js CHANGED
@@ -41,7 +41,7 @@ export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, } f
41
41
  * app commits and answers for — `createBridgeRelabeller` takes them as a
42
42
  * parameter so no app inherits another's.
43
43
  */
44
- export { FEDERATION_NETWORKS, BSKY_NETWORK_DOMAIN, blueskyUsernameFromHandle, createBridgeRelabeller, stripBridgeBoilerplate, upstreamProfileUrl, parseUpstreamProfileUrl, federatedUsernameFromUpstreamUrl, upstreamHandleFromProfileField, upstreamHandleFromAlsoKnownAs, upstreamHandleFromAutomatedActor, upstreamHandleFromPreferredUsername, upstreamHandleFromProxyOf, readProxyDeclarations, } from './networkIdentity.js';
44
+ export { FEDERATION_NETWORKS, BSKY_NETWORK_DOMAIN, blueskyUsernameFromHandle, blueskyHandleFromUsername, UNRESOLVED_ATPROTO_HANDLE, isUnresolvedAtprotoHandle, atprotoIdentityHandle, createBridgeRelabeller, stripBridgeBoilerplate, upstreamProfileUrl, parseUpstreamProfileUrl, federatedUsernameFromUpstreamUrl, upstreamHandleFromProfileField, upstreamHandleFromAlsoKnownAs, upstreamHandleFromAutomatedActor, upstreamHandleFromPreferredUsername, upstreamHandleFromProxyOf, readProxyDeclarations, } from './networkIdentity.js';
45
45
  /**
46
46
  * The shared JSON-LD `@context` (load-bearing term declarations) and the
47
47
  * ActivityPub URI helpers (actor-uri extraction + the per-instance domain policy:
@@ -409,6 +409,44 @@ export function blueskyUsernameFromHandle(handle) {
409
409
  ? handle.slice(0, -suffix.length)
410
410
  : handle;
411
411
  }
412
+ /**
413
+ * The inverse of {@link blueskyUsernameFromHandle}: the atproto handle a stored
414
+ * Bluesky username names. A username without a dot was a default
415
+ * `.bsky.social` handle; one with a dot is a custom domain, kept whole.
416
+ */
417
+ export function blueskyHandleFromUsername(username) {
418
+ return username.includes('.') ? username : `${username}.${FEDERATION_NETWORKS.bluesky.domain}`;
419
+ }
420
+ /**
421
+ * The literal handle a Bluesky AppView serves when a handle's bidirectional
422
+ * DNS/DID verification FAILS.
423
+ *
424
+ * It is an ERROR STRING, not an identity: every account whose handle cannot be
425
+ * verified gets the same one, so it is the single value in the atproto namespace
426
+ * guaranteed NOT to be unique. Keying an account on it collapses every such
427
+ * account onto one identity. Use {@link atprotoIdentityHandle}.
428
+ */
429
+ export const UNRESOLVED_ATPROTO_HANDLE = 'handle.invalid';
430
+ /**
431
+ * True when a handle is the unresolved-handle sentinel rather than a real one.
432
+ * Compared case-insensitively on the trimmed value: a handle is a DNS name.
433
+ */
434
+ export function isUnresolvedAtprotoHandle(handle) {
435
+ return handle.trim().toLowerCase() === UNRESOLVED_ATPROTO_HANDLE;
436
+ }
437
+ /**
438
+ * The handle an atproto account is IDENTIFIED by: its real handle, or its DID
439
+ * when the AppView could not verify one ({@link UNRESOLVED_ATPROTO_HANDLE}).
440
+ *
441
+ * The DID is the identifier atproto guarantees stable and unique. It contains a
442
+ * `:`, which no DNS handle may, so the substituted identity never collides with
443
+ * a real one, and `did:plc:…@bsky.social` splits on its `@` like any other
444
+ * acct. Nothing is stored under the sentinel, so the next refresh after the
445
+ * account fixes its DNS re-derives the real handle through the ordinary path.
446
+ */
447
+ export function atprotoIdentityHandle(handle, did) {
448
+ return isUnresolvedAtprotoHandle(handle) ? did : handle;
449
+ }
412
450
  /**
413
451
  * Build the readers for a set of reviewed bridge entries.
414
452
  *
@@ -340,6 +340,9 @@ export class ActorResolver {
340
340
  const alsoKnownAs = Array.isArray(actor.alsoKnownAs)
341
341
  ? actor.alsoKnownAs.filter((v) => typeof v === 'string')
342
342
  : undefined;
343
+ // A move target is an actor URL; anything else is not one.
344
+ const movedToValue = asString(actor.movedTo);
345
+ const movedTo = movedToValue && /^https?:\/\//.test(movedToValue) ? movedToValue : undefined;
343
346
  // The IDENTITY the actor is stored under in Oxy, which is not necessarily the
344
347
  // host it was fetched from — see `DeriveNetworkIdentity`. Everything below
345
348
  // that addresses the actor over the PROTOCOL (`acct`, `uri`, `domain`) is
@@ -387,12 +390,14 @@ export class ActorResolver {
387
390
  type: asString(actor.type) || 'Person',
388
391
  manuallyApprovesFollowers: actor.manuallyApprovesFollowers === true,
389
392
  discoverable: actor.discoverable !== false,
393
+ ...(typeof actor.indexable === 'boolean' && { indexable: actor.indexable }),
390
394
  memorial: actor.memorial === true,
391
395
  suspended: actor.suspended === true,
392
396
  fields,
393
397
  featuredUrl: asString(actor.featured) || undefined,
394
398
  featuredTagsUrl: asString(actor.featuredTags) || undefined,
395
399
  alsoKnownAs,
400
+ ...(movedTo && { movedTo }),
396
401
  networkAcct: networkIdentity?.federatedUsername,
397
402
  remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
398
403
  // Omitted, not written as `undefined`, when this refresh could not tell:
@@ -41,7 +41,7 @@ export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, typ
41
41
  * app commits and answers for — `createBridgeRelabeller` takes them as a
42
42
  * parameter so no app inherits another's.
43
43
  */
44
- export { FEDERATION_NETWORKS, BSKY_NETWORK_DOMAIN, blueskyUsernameFromHandle, createBridgeRelabeller, stripBridgeBoilerplate, upstreamProfileUrl, parseUpstreamProfileUrl, federatedUsernameFromUpstreamUrl, upstreamHandleFromProfileField, upstreamHandleFromAlsoKnownAs, upstreamHandleFromAutomatedActor, upstreamHandleFromPreferredUsername, upstreamHandleFromProxyOf, readProxyDeclarations, type FederationNetwork, type FederationBridgeEntry, type BridgeRelabeller, type BridgeConsentModel, type BridgeDerivation, type BridgedActorField, type DeriveNetworkIdentity, type NetworkIdentity, type NetworkIdentityCandidate, type ProxyDeclaration, } from './networkIdentity';
44
+ export { FEDERATION_NETWORKS, BSKY_NETWORK_DOMAIN, blueskyUsernameFromHandle, blueskyHandleFromUsername, UNRESOLVED_ATPROTO_HANDLE, isUnresolvedAtprotoHandle, atprotoIdentityHandle, createBridgeRelabeller, stripBridgeBoilerplate, upstreamProfileUrl, parseUpstreamProfileUrl, federatedUsernameFromUpstreamUrl, upstreamHandleFromProfileField, upstreamHandleFromAlsoKnownAs, upstreamHandleFromAutomatedActor, upstreamHandleFromPreferredUsername, upstreamHandleFromProxyOf, readProxyDeclarations, type FederationNetwork, type FederationBridgeEntry, type BridgeRelabeller, type BridgeConsentModel, type BridgeDerivation, type BridgedActorField, type DeriveNetworkIdentity, type NetworkIdentity, type NetworkIdentityCandidate, type ProxyDeclaration, } from './networkIdentity';
45
45
  /**
46
46
  * The shared JSON-LD `@context` (load-bearing term declarations) and the
47
47
  * ActivityPub URI helpers (actor-uri extraction + the per-instance domain policy:
@@ -464,6 +464,38 @@ export declare function upstreamHandleFromPreferredUsername(markers: readonly Re
464
464
  * whole handle is kept.
465
465
  */
466
466
  export declare function blueskyUsernameFromHandle(handle: string): string;
467
+ /**
468
+ * The inverse of {@link blueskyUsernameFromHandle}: the atproto handle a stored
469
+ * Bluesky username names. A username without a dot was a default
470
+ * `.bsky.social` handle; one with a dot is a custom domain, kept whole.
471
+ */
472
+ export declare function blueskyHandleFromUsername(username: string): string;
473
+ /**
474
+ * The literal handle a Bluesky AppView serves when a handle's bidirectional
475
+ * DNS/DID verification FAILS.
476
+ *
477
+ * It is an ERROR STRING, not an identity: every account whose handle cannot be
478
+ * verified gets the same one, so it is the single value in the atproto namespace
479
+ * guaranteed NOT to be unique. Keying an account on it collapses every such
480
+ * account onto one identity. Use {@link atprotoIdentityHandle}.
481
+ */
482
+ export declare const UNRESOLVED_ATPROTO_HANDLE = "handle.invalid";
483
+ /**
484
+ * True when a handle is the unresolved-handle sentinel rather than a real one.
485
+ * Compared case-insensitively on the trimmed value: a handle is a DNS name.
486
+ */
487
+ export declare function isUnresolvedAtprotoHandle(handle: string): boolean;
488
+ /**
489
+ * The handle an atproto account is IDENTIFIED by: its real handle, or its DID
490
+ * when the AppView could not verify one ({@link UNRESOLVED_ATPROTO_HANDLE}).
491
+ *
492
+ * The DID is the identifier atproto guarantees stable and unique. It contains a
493
+ * `:`, which no DNS handle may, so the substituted identity never collides with
494
+ * a real one, and `did:plc:…@bsky.social` splits on its `@` like any other
495
+ * acct. Nothing is stored under the sentinel, so the next refresh after the
496
+ * account fixes its DNS re-derives the real handle through the ordinary path.
497
+ */
498
+ export declare function atprotoIdentityHandle(handle: string, did: string): string;
467
499
  /** A reviewed bridge registry, and the readers an app drives it with. */
468
500
  export interface BridgeRelabeller {
469
501
  /** The reviewed entry for a host, or `undefined` — most hosts are not bridges. */
@@ -60,12 +60,19 @@ export interface FederatedActorUpsert {
60
60
  type: string;
61
61
  manuallyApprovesFollowers: boolean;
62
62
  discoverable: boolean;
63
+ /**
64
+ * Mastodon's opt-in to search engines and full-text search (`indexable`).
65
+ * Absent when the actor does not say, which Mastodon reads as "no".
66
+ */
67
+ indexable?: boolean;
63
68
  memorial: boolean;
64
69
  suspended: boolean;
65
70
  fields: FederatedActorField[];
66
71
  featuredUrl?: string;
67
72
  featuredTagsUrl?: string;
68
73
  alsoKnownAs?: string[];
74
+ /** The account this actor moved to (`movedTo`), when it advertises one. */
75
+ movedTo?: string;
69
76
  /**
70
77
  * The `<handle>@<network-domain>` identity this actor was re-labelled onto, when
71
78
  * it came from a bridge; absent for the ordinary actor whose identity is simply
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -75,12 +75,11 @@
75
75
  "clean": "rm -rf dist",
76
76
  "typescript": "tsc --noEmit",
77
77
  "test": "jest --passWithNoTests",
78
- "lint": "biome lint --error-on-warnings ./src",
79
- "prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run clean && bun run build"
78
+ "lint": "biome lint --error-on-warnings ./src"
80
79
  },
81
80
  "dependencies": {
82
- "@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || 4.11.0-oxy-one.0",
83
- "@oxy.so/core": "^6.14.0"
81
+ "@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || 4.11.0-oxy-one.0",
82
+ "@oxy.so/core": "^6.14.0 || ^7.0.0"
84
83
  },
85
84
  "peerDependencies": {
86
85
  "express": "^4.18.0 || ^5.0.0"
@@ -91,13 +90,13 @@
91
90
  }
92
91
  },
93
92
  "devDependencies": {
94
- "@types/express": "^4.17.25",
93
+ "@types/express": "^5.0.6",
95
94
  "@types/jest": "^30.0.0",
96
- "@types/node": "^22.20.1",
97
- "express": "^4.22.2",
98
- "jest": "^30.5.1",
99
- "supertest": "^7.0.0",
100
- "ts-jest": "^29.4.12",
101
- "typescript": "^5.9.2"
95
+ "@types/node": "^26.6.6",
96
+ "express": "^5.3.0",
97
+ "jest": "^30.5.2",
98
+ "supertest": "^7.3.1",
99
+ "ts-jest": "^29.4.15",
100
+ "typescript": "~6.0.3"
102
101
  }
103
102
  }
@@ -102,6 +102,22 @@ async function resolveWith(replies: {
102
102
  return { upserts, bridged };
103
103
  }
104
104
 
105
+ describe('fetchRemoteActor — search opt-in and moves', () => {
106
+ it('stores indexable and movedTo when the actor states them, and omits them when not', async () => {
107
+ const moved = await resolveWith({
108
+ actor: actorDocument({ indexable: true, movedTo: 'https://new.example/users/bob' }),
109
+ });
110
+ expect(moved.upserts[0]).toMatchObject({
111
+ indexable: true,
112
+ movedTo: 'https://new.example/users/bob',
113
+ });
114
+
115
+ const silent = await resolveWith({ actor: actorDocument({ movedTo: 'not a url' }) });
116
+ expect(silent.upserts[0]).not.toHaveProperty('indexable');
117
+ expect(silent.upserts[0]).not.toHaveProperty('movedTo');
118
+ });
119
+ });
120
+
105
121
  describe('fetchRemoteActor — remote collection counts', () => {
106
122
  it('stores reported totals, and a real 0 stays 0', async () => {
107
123
  const { upserts } = await resolveWith({
@@ -10,6 +10,8 @@
10
10
 
11
11
  import {
12
12
  FEDERATION_NETWORKS,
13
+ atprotoIdentityHandle,
14
+ blueskyHandleFromUsername,
13
15
  blueskyUsernameFromHandle,
14
16
  createBridgeRelabeller,
15
17
  parseUpstreamProfileUrl,
@@ -390,6 +392,16 @@ describe('bsky.social is one network, whichever protocol an account arrives by',
390
392
  expect(blueskyUsernameFromHandle('jay.bsky.team')).toBe('jay.bsky.team');
391
393
  expect(blueskyUsernameFromHandle('bsky.social')).toBe('bsky.social');
392
394
  });
395
+
396
+ it('maps a stored username back to its handle', () => {
397
+ for (const handle of ['skylee1.bsky.social', 'gothamist.com', 'bsky.social'])
398
+ expect(blueskyHandleFromUsername(blueskyUsernameFromHandle(handle))).toBe(handle);
399
+ });
400
+
401
+ it("identifies an account whose handle does not verify by its DID, never Bluesky's sentinel", () => {
402
+ expect(atprotoIdentityHandle('alice.example', 'did:plc:abc')).toBe('alice.example');
403
+ expect(atprotoIdentityHandle(' Handle.Invalid ', 'did:plc:abc')).toBe('did:plc:abc');
404
+ });
393
405
  });
394
406
 
395
407
  describe('federatedUsernameFromUpstreamUrl — the search direction', () => {
package/src/index.ts CHANGED
@@ -65,6 +65,10 @@ export {
65
65
  FEDERATION_NETWORKS,
66
66
  BSKY_NETWORK_DOMAIN,
67
67
  blueskyUsernameFromHandle,
68
+ blueskyHandleFromUsername,
69
+ UNRESOLVED_ATPROTO_HANDLE,
70
+ isUnresolvedAtprotoHandle,
71
+ atprotoIdentityHandle,
68
72
  createBridgeRelabeller,
69
73
  stripBridgeBoilerplate,
70
74
  upstreamProfileUrl,
@@ -646,6 +646,48 @@ export function blueskyUsernameFromHandle(handle: string): string {
646
646
  : handle;
647
647
  }
648
648
 
649
+ /**
650
+ * The inverse of {@link blueskyUsernameFromHandle}: the atproto handle a stored
651
+ * Bluesky username names. A username without a dot was a default
652
+ * `.bsky.social` handle; one with a dot is a custom domain, kept whole.
653
+ */
654
+ export function blueskyHandleFromUsername(username: string): string {
655
+ return username.includes('.') ? username : `${username}.${FEDERATION_NETWORKS.bluesky.domain}`;
656
+ }
657
+
658
+ /**
659
+ * The literal handle a Bluesky AppView serves when a handle's bidirectional
660
+ * DNS/DID verification FAILS.
661
+ *
662
+ * It is an ERROR STRING, not an identity: every account whose handle cannot be
663
+ * verified gets the same one, so it is the single value in the atproto namespace
664
+ * guaranteed NOT to be unique. Keying an account on it collapses every such
665
+ * account onto one identity. Use {@link atprotoIdentityHandle}.
666
+ */
667
+ export const UNRESOLVED_ATPROTO_HANDLE = 'handle.invalid';
668
+
669
+ /**
670
+ * True when a handle is the unresolved-handle sentinel rather than a real one.
671
+ * Compared case-insensitively on the trimmed value: a handle is a DNS name.
672
+ */
673
+ export function isUnresolvedAtprotoHandle(handle: string): boolean {
674
+ return handle.trim().toLowerCase() === UNRESOLVED_ATPROTO_HANDLE;
675
+ }
676
+
677
+ /**
678
+ * The handle an atproto account is IDENTIFIED by: its real handle, or its DID
679
+ * when the AppView could not verify one ({@link UNRESOLVED_ATPROTO_HANDLE}).
680
+ *
681
+ * The DID is the identifier atproto guarantees stable and unique. It contains a
682
+ * `:`, which no DNS handle may, so the substituted identity never collides with
683
+ * a real one, and `did:plc:…@bsky.social` splits on its `@` like any other
684
+ * acct. Nothing is stored under the sentinel, so the next refresh after the
685
+ * account fixes its DNS re-derives the real handle through the ordinary path.
686
+ */
687
+ export function atprotoIdentityHandle(handle: string, did: string): string {
688
+ return isUnresolvedAtprotoHandle(handle) ? did : handle;
689
+ }
690
+
649
691
  /** A reviewed bridge registry, and the readers an app drives it with. */
650
692
  export interface BridgeRelabeller {
651
693
  /** The reviewed entry for a host, or `undefined` — most hosts are not bridges. */
@@ -148,12 +148,19 @@ export interface FederatedActorUpsert {
148
148
  type: string;
149
149
  manuallyApprovesFollowers: boolean;
150
150
  discoverable: boolean;
151
+ /**
152
+ * Mastodon's opt-in to search engines and full-text search (`indexable`).
153
+ * Absent when the actor does not say, which Mastodon reads as "no".
154
+ */
155
+ indexable?: boolean;
151
156
  memorial: boolean;
152
157
  suspended: boolean;
153
158
  fields: FederatedActorField[];
154
159
  featuredUrl?: string;
155
160
  featuredTagsUrl?: string;
156
161
  alsoKnownAs?: string[];
162
+ /** The account this actor moved to (`movedTo`), when it advertises one. */
163
+ movedTo?: string;
157
164
  /**
158
165
  * The `<handle>@<network-domain>` identity this actor was re-labelled onto, when
159
166
  * it came from a bridge; absent for the ordinary actor whose identity is simply
@@ -598,6 +605,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
598
605
  const alsoKnownAs = Array.isArray(actor.alsoKnownAs)
599
606
  ? actor.alsoKnownAs.filter((v): v is string => typeof v === 'string')
600
607
  : undefined;
608
+ // A move target is an actor URL; anything else is not one.
609
+ const movedToValue = asString(actor.movedTo);
610
+ const movedTo = movedToValue && /^https?:\/\//.test(movedToValue) ? movedToValue : undefined;
601
611
 
602
612
  // The IDENTITY the actor is stored under in Oxy, which is not necessarily the
603
613
  // host it was fetched from — see `DeriveNetworkIdentity`. Everything below
@@ -647,12 +657,14 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
647
657
  type: asString(actor.type) || 'Person',
648
658
  manuallyApprovesFollowers: actor.manuallyApprovesFollowers === true,
649
659
  discoverable: actor.discoverable !== false,
660
+ ...(typeof actor.indexable === 'boolean' && { indexable: actor.indexable }),
650
661
  memorial: actor.memorial === true,
651
662
  suspended: actor.suspended === true,
652
663
  fields,
653
664
  featuredUrl: asString(actor.featured) || undefined,
654
665
  featuredTagsUrl: asString(actor.featuredTags) || undefined,
655
666
  alsoKnownAs,
667
+ ...(movedTo && { movedTo }),
656
668
  networkAcct: networkIdentity?.federatedUsername,
657
669
  remoteCreatedAt:
658
670
  typeof actor.published === 'string' ? new Date(actor.published) : undefined,