Changeset ccaa530 in Klonkt


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@…>

Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r2dd1dc4 rccaa530  
    143143  ensureColumn('sites', 'external_playback', 'INTEGER');
    144144  ensureColumn('sites', 'og_theme', 'TEXT');             // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
     145  // FEP-7628: former identities this actor claims (JSON array of actor URIs).
     146  // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
     147  // of its followers to this account — the claim must be visible on OUR side.
     148  ensureColumn('sites', 'ap_aliases', 'TEXT');
    145149
    146150  // Per-post noindex + type
  • src/routes/admin-sites.js

    r2dd1dc4 rccaa530  
    2626import { toWebp } from '../services/ImageWebpService.js';
    2727import { mediaDir } from '../config/paths.js';
     28import AP from '../services/ActivityPubService.js';
    2829
    2930
     
    7172  }
    7273  return arr.length ? JSON.stringify(arr) : null;
     74}
     75
     76/**
     77 * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
     78 * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
     79 * purpose — a typo'd alias that silently lands on the actor would make a later
     80 * Move fail at the old server with no hint why. Throws the offending line.
     81 */
     82async function parseApAliases(raw, ownActorUri) {
     83  const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
     84  if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
     85  const out = [];
     86  for (const line of lines) {
     87    let uri = null;
     88    if (/^https?:\/\//i.test(line)) uri = line;
     89    else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
     90    if (!uri) throw new Error(line);
     91    if (uri === ownActorUri) continue; // claiming yourself adds nothing
     92    if (!out.includes(uri)) out.push(uri);
     93  }
     94  return out;
    7395}
    7496
     
    256278  }
    257279
     280  let apAliases = '';
     281  try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
     282
    258283  renderPage(req, res, 'pages/admin-site-edit', {
    259284    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
     
    266291    platforms: listPlatforms(),
    267292    parsedLinks,
     293    apAliases,
    268294    success: req.query.success || null,
    269295    error: req.query.error || null,
     
    272298
    273299// ==================== SAVE ====================
    274 router.post('/:slug/save', requireSiteManagerBySlug, (req, res) => {
    275   const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
     300router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
     301  const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
    276302  if (!site) return res.redirect('/admin/sites?error=Not+found');
    277303
     
    279305  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
    280306  const profileLinksJson = buildProfileLinks(f);
     307
     308  // FEP-7628 aliases — validated/resolved before anything is written.
     309  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     310  let apAliasesJson = null;
     311  try {
     312    const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
     313    apAliasesJson = arr.length ? JSON.stringify(arr) : null;
     314  } catch (e) {
     315    return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
     316  }
    281317
    282318  // theme_override: only accept the three legal values. Empty string means
     
    294330      profile_enabled = ?,
    295331      profile_links = ?,
     332      ap_aliases = ?,
    296333      is_public = ?, robots_index = ?, require_login_to_comment = ?,
    297334      enable_audio_player = ?,
     
    312349    f.profile_enabled ? 1 : 0,
    313350    profileLinksJson,
     351    apAliasesJson,
    314352    f.is_public ? 1 : 0,
    315353    f.robots_index ? 1 : 0,
     
    336374  }
    337375
     376  // Alias change → broadcast an actor Update so remote caches refresh. The old
     377  // server re-fetches the actor live during a Move anyway; this is freshness,
     378  // not correctness, hence best-effort.
     379  if ((site.ap_aliases || null) !== apAliasesJson) {
     380    try {
     381      const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
     382      AP.deliverActorUpdate(fresh).catch(() => {});
     383    } catch { /* never blocks the save */ }
     384  }
     385
    338386  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
    339387});
  • 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,
  • src/services/i18n.js

    r2dd1dc4 rccaa530  
    252252    'asite.links': 'Social / streaming-links',
    253253    'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.',
     254    'asite.aliases': 'Fediverse-aliassen',
     255    'asite.aliases_hint': 'Eén per regel: je oude account als @naam@server of als actor-URL. Nodig om volgers van een oud account hierheen te verhuizen; de oude server controleert of dit profiel het oude claimt.',
    254256    'asite.link_add': '+ Link toevoegen',
    255257    'asite.feed_view': 'Feed-weergave',
     
    11881190    'asite.links': 'Social / streaming links',
    11891191    'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.',
     1192    'asite.aliases': 'Fediverse aliases',
     1193    'asite.aliases_hint': 'One per line: your old account as @name@server or as an actor URL. Needed to move followers from an old account to this one; the old server checks that this profile claims the old one.',
    11901194    'asite.link_add': '+ Add link',
    11911195    'asite.feed_view': 'Feed display',
     
    21232127    'asite.links': 'Social- / Streaming-Links',
    21242128    'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.',
     2129    'asite.aliases': 'Fediverse-Aliasse',
     2130    'asite.aliases_hint': 'Einer pro Zeile: dein altes Konto als @name@server oder als Actor-URL. Nötig, um Follower eines alten Kontos hierher umzuziehen; der alte Server prüft, ob dieses Profil das alte beansprucht.',
    21252131    'asite.link_add': '+ Link hinzufügen',
    21262132    'asite.feed_view': 'Feed-Anzeige',
  • src/views/pages/admin-site-edit.ejs

    r2dd1dc4 rccaa530  
    211211
    212212      <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
     213    </fieldset>
     214
     215    <fieldset>
     216      <legend><%= t('asite.aliases') %></legend>
     217      <p class="form-hint"><%= t('asite.aliases_hint') %></p>
     218      <label>
     219        <textarea name="ap_aliases" rows="3" placeholder="@oud@mastodon.social&#10;https://andere-klonkt.example/ap/users/naam"><%= apAliases %></textarea>
     220      </label>
    213221    </fieldset>
    214222
Note: See TracChangeset for help on using the changeset viewer.