@oxy.so/federation 2.12.0 → 2.14.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:
@@ -7,8 +7,8 @@
7
7
  * because those are identical across every Oxy app: they bridge a federated follow
8
8
  * edge into the Oxy graph (via the identity adapter), record the AP-side follow
9
9
  * row (via the store adapter), and send the Accept back (via the delivery
10
- * service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
11
- * non-follow Undo) is handed to the app-registered
10
+ * service). Every CONTENT verb (Create / Announce / Like / Delete / Update /
11
+ * Add / Remove, and a non-follow Undo) is handed to the app-registered
12
12
  * {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
13
13
  * post/engagement handlers live. The consent gate + notification side effects are
14
14
  * injected so the engine holds no app knowledge.
@@ -299,12 +299,15 @@ function createInboundDispatcher(config) {
299
299
  case 'Reject':
300
300
  await handleReject(activity, verifiedActorUri);
301
301
  break;
302
- // Content verbs — the app owns these (posts, engagement, actor profile edits).
302
+ // Content verbs — the app owns these (posts, engagement, actor profile
303
+ // edits, and collection changes such as pinned posts).
303
304
  case 'Create':
304
305
  case 'Delete':
305
306
  case 'Like':
306
307
  case 'Announce':
307
308
  case 'Update':
309
+ case 'Add':
310
+ case 'Remove':
308
311
  await config.onContentActivity(activity, verifiedActorUri);
309
312
  break;
310
313
  case 'Move': {
@@ -54,7 +54,7 @@ Object.defineProperty(exports, "DeliveryAuthorizationDeniedError", { enumerable:
54
54
  * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
55
55
  * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
56
56
  * identity + store adapters, and the `onContentActivity` seam every content verb
57
- * (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
57
+ * (Create / Announce / Like / Delete / Update / Add / Remove, non-follow Undo) is handed to.
58
58
  */
59
59
  var inboundDispatch_1 = require("./inboundDispatch");
60
60
  Object.defineProperty(exports, "createInboundDispatcher", { enumerable: true, get: function () { return inboundDispatch_1.createInboundDispatcher; } });
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:
@@ -6,8 +6,8 @@
6
6
  * because those are identical across every Oxy app: they bridge a federated follow
7
7
  * edge into the Oxy graph (via the identity adapter), record the AP-side follow
8
8
  * row (via the store adapter), and send the Accept back (via the delivery
9
- * service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
10
- * non-follow Undo) is handed to the app-registered
9
+ * service). Every CONTENT verb (Create / Announce / Like / Delete / Update /
10
+ * Add / Remove, and a non-follow Undo) is handed to the app-registered
11
11
  * {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
12
12
  * post/engagement handlers live. The consent gate + notification side effects are
13
13
  * injected so the engine holds no app knowledge.
@@ -294,12 +294,15 @@ export function createInboundDispatcher(config) {
294
294
  case 'Reject':
295
295
  await handleReject(activity, verifiedActorUri);
296
296
  break;
297
- // Content verbs — the app owns these (posts, engagement, actor profile edits).
297
+ // Content verbs — the app owns these (posts, engagement, actor profile
298
+ // edits, and collection changes such as pinned posts).
298
299
  case 'Create':
299
300
  case 'Delete':
300
301
  case 'Like':
301
302
  case 'Announce':
302
303
  case 'Update':
304
+ case 'Add':
305
+ case 'Remove':
303
306
  await config.onContentActivity(activity, verifiedActorUri);
304
307
  break;
305
308
  case 'Move': {
@@ -44,7 +44,7 @@ export { createDeliveryService, DeliveryAuthorizationDeniedError, } from './deli
44
44
  * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
45
45
  * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
46
46
  * identity + store adapters, and the `onContentActivity` seam every content verb
47
- * (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
47
+ * (Create / Announce / Like / Delete / Update / Add / Remove, non-follow Undo) is handed to.
48
48
  */
49
49
  export { createInboundDispatcher, ActorResolutionPendingError, } from './inboundDispatch.js';
50
50
  /** The WebFinger + host-meta discovery router (domain-parameterized, consent-gated). */
@@ -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
@@ -6,8 +6,8 @@
6
6
  * because those are identical across every Oxy app: they bridge a federated follow
7
7
  * edge into the Oxy graph (via the identity adapter), record the AP-side follow
8
8
  * row (via the store adapter), and send the Accept back (via the delivery
9
- * service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
10
- * non-follow Undo) is handed to the app-registered
9
+ * service). Every CONTENT verb (Create / Announce / Like / Delete / Update /
10
+ * Add / Remove, and a non-follow Undo) is handed to the app-registered
11
11
  * {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
12
12
  * post/engagement handlers live. The consent gate + notification side effects are
13
13
  * injected so the engine holds no app knowledge.
@@ -157,7 +157,9 @@ export interface InboundDispatcherConfig {
157
157
  onOutboundFollowAccepted?(actorUri: string): Promise<void>;
158
158
  /**
159
159
  * Handle a CONTENT activity the engine does not own — Create / Announce / Like /
160
- * Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
160
+ * Delete / Update / Add / Remove, and a non-follow Undo. The app's
161
+ * post/engagement handlers; `Add`/`Remove` carry collection changes such as a
162
+ * Mastodon pin (the status added to or removed from the actor's `featured`).
161
163
  */
162
164
  onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
163
165
  /**
@@ -44,7 +44,7 @@ export { createDeliveryService, DeliveryAuthorizationDeniedError, type DeliveryA
44
44
  * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
45
45
  * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
46
46
  * identity + store adapters, and the `onContentActivity` seam every content verb
47
- * (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
47
+ * (Create / Announce / Like / Delete / Update / Add / Remove, non-follow Undo) is handed to.
48
48
  */
49
49
  export { createInboundDispatcher, ActorResolutionPendingError, type InboundMove, type InboundDispatcher, type InboundDispatcherConfig, type InboundDispatcherLogger, type InboundActivityValidation, type InboundLocalUser, type InboundIdentity, type InboundConsent, type InboundActorResolver, type InboundFollowStore, type InboundDelivery, } from './inboundDispatch';
50
50
  /** The WebFinger + host-meta discovery router (domain-parameterized, consent-gated). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.12.0",
3
+ "version": "2.14.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",
@@ -90,13 +90,13 @@
90
90
  }
91
91
  },
92
92
  "devDependencies": {
93
- "@types/express": "^4.17.25",
93
+ "@types/express": "^5.0.6",
94
94
  "@types/jest": "^30.0.0",
95
- "@types/node": "^22.20.1",
96
- "express": "^4.22.2",
97
- "jest": "^30.5.1",
98
- "supertest": "^7.0.0",
99
- "ts-jest": "^29.4.12",
100
- "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"
101
101
  }
102
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({
@@ -375,7 +375,7 @@ describe('Move → onMove', () => {
375
375
  });
376
376
 
377
377
  describe('content verbs → onContentActivity', () => {
378
- it.each(['Create', 'Announce', 'Like', 'Delete', 'Update'])(
378
+ it.each(['Create', 'Announce', 'Like', 'Delete', 'Update', 'Add', 'Remove'])(
379
379
  'routes %s to the app',
380
380
  async (type) => {
381
381
  const rig = makeRig();
@@ -404,7 +404,7 @@ describe('content verbs → onContentActivity', () => {
404
404
  describe('blocked-origin domain policy', () => {
405
405
  const BLOCKED_ACTOR = 'https://spam.example/users/mallory';
406
406
 
407
- it.each(['Create', 'Announce', 'Like', 'Delete', 'Update'])(
407
+ it.each(['Create', 'Announce', 'Like', 'Delete', 'Update', 'Add', 'Remove'])(
408
408
  'drops %s from a blocked origin without reaching the app',
409
409
  async (type) => {
410
410
  const rig = makeRig({ blockedHosts: ['spam.example'] });
@@ -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,
@@ -6,8 +6,8 @@
6
6
  * because those are identical across every Oxy app: they bridge a federated follow
7
7
  * edge into the Oxy graph (via the identity adapter), record the AP-side follow
8
8
  * row (via the store adapter), and send the Accept back (via the delivery
9
- * service). Every CONTENT verb (Create / Announce / Like / Delete / Update, and a
10
- * non-follow Undo) is handed to the app-registered
9
+ * service). Every CONTENT verb (Create / Announce / Like / Delete / Update /
10
+ * Add / Remove, and a non-follow Undo) is handed to the app-registered
11
11
  * {@link InboundDispatcherConfig.onContentActivity} callback, where the app's own
12
12
  * post/engagement handlers live. The consent gate + notification side effects are
13
13
  * injected so the engine holds no app knowledge.
@@ -186,7 +186,9 @@ export interface InboundDispatcherConfig {
186
186
  onOutboundFollowAccepted?(actorUri: string): Promise<void>;
187
187
  /**
188
188
  * Handle a CONTENT activity the engine does not own — Create / Announce / Like /
189
- * Delete / Update, and a non-follow Undo. The app's post/engagement handlers.
189
+ * Delete / Update / Add / Remove, and a non-follow Undo. The app's
190
+ * post/engagement handlers; `Add`/`Remove` carry collection changes such as a
191
+ * Mastodon pin (the status added to or removed from the actor's `featured`).
190
192
  */
191
193
  onContentActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
192
194
  /**
@@ -499,12 +501,15 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
499
501
  case 'Reject':
500
502
  await handleReject(activity, verifiedActorUri);
501
503
  break;
502
- // Content verbs — the app owns these (posts, engagement, actor profile edits).
504
+ // Content verbs — the app owns these (posts, engagement, actor profile
505
+ // edits, and collection changes such as pinned posts).
503
506
  case 'Create':
504
507
  case 'Delete':
505
508
  case 'Like':
506
509
  case 'Announce':
507
510
  case 'Update':
511
+ case 'Add':
512
+ case 'Remove':
508
513
  await config.onContentActivity(activity, verifiedActorUri);
509
514
  break;
510
515
  case 'Move': {
package/src/node/index.ts CHANGED
@@ -109,7 +109,7 @@ export {
109
109
  * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
110
110
  * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
111
111
  * identity + store adapters, and the `onContentActivity` seam every content verb
112
- * (Create / Announce / Like / Delete / Update, non-follow Undo) is handed to.
112
+ * (Create / Announce / Like / Delete / Update / Add / Remove, non-follow Undo) is handed to.
113
113
  */
114
114
  export {
115
115
  createInboundDispatcher,