Ignore:
Timestamp:
07/31/2026 01:34:46 PM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
5462bab
Parents:
2dd1dc4
Message:

Account-verhuizingen: inkomende Move plus alsoKnownAs-aliassen (FEP-7628)

De ontvangende helft van accountverhuizingen, in twee delen die samen een
verhuizing NAAR Klonkt mogelijk maken (shaer-0j2, slice 1).

Deel 1: een account dat onze sites volgen verhuist. Op een geldige Move volgt
elke lokale volger automatisch mee naar het nieuwe adres, met behoud van de
eigen auto-boost-keuze. Geldigheid heeft twee onafhankelijke benen en beide
zijn verplicht: de ondertekenaar is zelf partij in de verhuizing (oude actor
in push-modus of nieuwe actor in pull-modus), en de nieuwe actor claimt de
oude identiteit in alsoKnownAs. Zonder het eerste kan elke ondertekende
vreemde onze follows ompointen; zonder het tweede kan wie een kant beheerst
de volgers van de andere kant kapen. Herbezorging is idempotent, een
geblokkeerde bestemming wordt geweigerd, en guardianship-relaties worden
bewust NIET geretarget: een guardian is een beveiligingsanker en verhuist
pas onder de regels van shaer-tge.

Deel 2: eigen aliassen instellen via Beheer, Sites, site bewerken. Een regel
per oud account, als @naam@server of als actor-URL. Handles resolven via
WebFinger op het moment van opslaan, zodat een typfout direct zichtbaar is
in plaats van pas bij een mislukkende Move op de oude server. De aliassen
verschijnen als alsoKnownAs op de publieke actor; dat is de claim die de
oude server controleert voor hij volgers hierheen verhuist.

Let op: FEP-7628 heeft status DRAFT. De vorm is sinds 2019 de facto
Mastodon-standaard, maar de spec kan nog wijzigen.

Changed files:
src/services/ActivityPubService.js

  • Move toegevoegd aan de GATED-lijst (handtekening verplicht)
  • dispatch-blok voor Move, voor de Follow-afhandeling (geen race met de re-Follow-golf van de nieuwe actor)
  • handleMoveInbox met injecteerbare afhankelijkheden voor de tests
  • alsoKnownAs en movedTo als JSON-LD-termen in de context
  • buildActor publiceert alsoKnownAs uit sites.ap_aliases, eigen id en niet-URLs gefilterd

src/config/database.js

  • kolom sites.ap_aliases (JSON-array van actor-URIs)

src/routes/admin-sites.js

  • parseApAliases: max 5, dedupe, WebFinger-resolutie bij opslaan, fout met de betreffende regel terug naar het formulier
  • save-route asynchroon; ap_aliases in de UPDATE
  • actor-Update naar volgers wanneer de lijst wijzigt (best-effort)

src/views/pages/admin-site-edit.ejs

  • veld Fediverse-aliassen onder de profiel-links

src/services/i18n.js

  • asite.aliases en asite.aliases_hint in nl, en en de

New file:
test/move-actor.test.js

  • 11 tests: derde-partij-weigering, ontbrekend aliasbewijs, push- en pull-modus, idempotentie, al-volgend overslaan, geblokkeerd doel, misvormde activiteiten, ongesigneerd, actor-publicatie met filtering

-robo
Co-Authored-By: Claude Fable 5 <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r2dd1dc4 rccaa530  
    4646    manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
    4747    discoverable: 'toot:discoverable',
     48    // FEP-7628 (account moves): same term declaration Mastodon ships.
     49    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
     50    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
    4851    featured: { '@id': 'toot:featured', '@type': '@id' },
    4952    PropertyValue: 'schema:PropertyValue',
     
    221224  // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
    222225  if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
     226  // FEP-7628: former identities this account claims. The OLD server checks for
     227  // exactly this back-reference before it will move followers here, so the
     228  // list must be on the public actor, not tucked away in settings.
     229  try {
     230    const aka = JSON.parse(site.ap_aliases || '[]');
     231    if (Array.isArray(aka)) {
     232      const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
     233      if (clean.length) actor.alsoKnownAs = clean;
     234    }
     235  } catch { /* skip malformed ap_aliases */ }
    223236  // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
    224237  // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
     
    14371450  // Blocked actor/domain → silently drop (202, don't reveal the block).
    14381451  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
    1439   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
     1452  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    14401453  if (GATED.includes(type)) {
    14411454    if (!verified || !claimedActor || verified.id !== claimedActor) {
     
    15131526    } catch { /* ignore */ }
    15141527    return 202;
     1528  }
     1529
     1530  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
     1531  // a Move often arrives seconds before the new actor's re-Follow wave, and the
     1532  // swap below must not race our own outgoing Follow of the target.
     1533  if (type === 'Move') {
     1534    return handleMoveInbox(act, { verifiedActor: claimedActor });
    15151535  }
    15161536
     
    37603780}
    37613781
     3782/**
     3783 * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
     3784 * still change): an account our sites follow says it moved to a new home.
     3785 *
     3786 * Validity has two independent legs, and both must hold:
     3787 *  1. The SIGNER is a party to the move: the old actor announcing its own move
     3788 *     (push mode) or the new actor doing it (pull mode). A third party
     3789 *     narrating someone else's move is refused — without this, any signed
     3790 *     stranger could re-point our follows.
     3791 *  2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
     3792 *     cross-side proof: the mover controls both ends. Without it, whoever
     3793 *     holds ONE end could hijack the other end's followers.
     3794 *
     3795 * Effect: every local site following the old actor unfollows it and follows
     3796 * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
     3797 * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
     3798 * feed subscription, and moving one is shaer-tge's gated decision, not a
     3799 * side effect of an inbox event. We only log when a move touches one.
     3800 *
     3801 * Deps are injectable for tests (no network in node:test).
     3802 */
     3803export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
     3804  const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
     3805  const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
     3806  if (!oldUri || !newUri || oldUri === newUri) return 400;
     3807  if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
     3808    console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
     3809    return 401;
     3810  }
     3811  // Nobody here follows the old actor → nothing to move. This also makes
     3812  // redelivery idempotent: after the first swap the rows are gone.
     3813  let rows = [];
     3814  try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
     3815  if (!rows.length) return 202;
     3816  // A blocked destination is declined outright: the old follow stays (it goes
     3817  // stale on its own), and we will not open a door to a blocked house.
     3818  if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
     3819  const target = await (fetchActorFn || fetchActor)(newUri);
     3820  const aka = [].concat((target && target.alsoKnownAs) || [])
     3821    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
     3822  if (!target || !target.id || !aka.includes(oldUri)) {
     3823    console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
     3824    return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
     3825  }
     3826  for (const row of rows) {
     3827    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
     3828    if (!site) continue;
     3829    try {
     3830      await (unfollowFn || unfollowActor)(site, oldUri);
     3831      const already = fwStmts().one.get(row.slug, newUri);
     3832      if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
     3833      console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
     3834    } catch (e) {
     3835      console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
     3836    }
     3837  }
     3838  try {
     3839    const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
     3840    if (g.length) console.warn('[AP] Move touches a guardianship party — left untouched (shaer-tge):', oldUri, '→', g.map((r) => `${r.role}:${r.slug}`).join(', '));
     3841  } catch { /* table absent on fresh init */ }
     3842  return 202;
     3843}
     3844
    37623845// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
    37633846/**
     
    42764359  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
    42774360  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    4278   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     4361  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    42794362  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
    42804363  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Note: See TracChangeset for help on using the changeset viewer.