Changeset 0ca7e9a4 in Klonkt


Ignore:
Timestamp:
07/31/2026 05:43:16 PM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
bfa6fa1
Parents:
f434294
Message:

Account-verhuizingen slice 2: de uitgaande Move (FEP-7628)

De vertrekkende helft van shaer-0j2: deze Klonkt als het oude huis. Twee
eisen voordat er iets de deur uit gaat. Een: geen guardians; een warded
account verhuizen zonder de guardianship te hertargeten zou het vangnet
van het kind stil breken, dus tot shaer-tge er is weigert een bewaakt
account met een heldere melding. Twee: het nieuwe profiel claimt dit
adres in alsoKnownAs, dezelfde back-reference die elke ontvangende
server (onze slice 1 incluis) eist; zonder die claim is de Move overal
dood bij aankomst.

De Move gaat duurzaam (retry-queue) naar elke volger-inbox; hun servers
doen de re-follow. moved_to wordt vastgelegd; het serveren ervan op de
actor en het beleid van de oude site zijn slice 3. In Beheer > Sites >
bewerken zit de aankondiging als eigen formulier met bevestiging, nooit
als bijeffect van Opslaan: een verhuizing is een deur die je achter je
dichttrekt.

Changed files:
src/services/ActivityPubService.js

  • moveAccount(site, target, {fetchActorFn, deliverFn}): guardian- weigering, resolutie (URL/handle), back-reference-check, moved_to, Move naar alle volger-inboxen (shared inbox de-dupe)

src/config/database.js

  • sites.moved_to (TEXT)

src/routes/admin-sites.js

  • POST /:slug/move met per-fout een heldere redirect-melding

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

  • Verhuizen-sectie (eigen form + confirm), toont moved_to

src/services/i18n.js

  • asite.move* in nl/en/de

test/move-actor.test.js

  • guarded weigert (niets vastgelegd), zonder back-reference weigert, happy path: moved_to + een Move per volger-inbox (shared voorop)

remarks: gebouwd op vps/main (f434294) in een worktree, los van de
lopende boost-toggle-sessie in de hoofd-clone. Suite op 370 groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
1 added
6 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rf434294 r0ca7e9a4  
    147147  // of its followers to this account — the claim must be visible on OUR side.
    148148  ensureColumn('sites', 'ap_aliases', 'TEXT');
     149  ensureColumn('sites', 'moved_to', 'TEXT');   // FEP-7628 slice 2: waarheen dit account vertrok
    149150
    150151  // Per-post noindex + type
  • src/routes/admin-sites.js

    rf434294 r0ca7e9a4  
    296296    error: req.query.error || null,
    297297  });
     298});
     299
     300// ==================== MOVE (FEP-7628, slice 2) ====================
     301// The explicit departure: announce to every follower that this account now
     302// lives elsewhere. Deliberately its own POST with its own button, never a
     303// side effect of Save: a Move is a door you close behind you.
     304router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
     305  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
     306  if (!site) return res.redirect('/admin/sites?error=Not+found');
     307  const r = await AP.moveAccount(site, req.body.move_target || '');
     308  if (r && r.ok) {
     309    return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
     310  }
     311  const msg = {
     312    guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
     313    no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
     314    not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
     315    unreachable: 'Het nieuwe profiel is niet bereikbaar.',
     316    self: 'Dat is dit account zelf.',
     317  }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
     318  res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
    298319});
    299320
  • src/services/ActivityPubService.js

    rf434294 r0ca7e9a4  
    38433843}
    38443844
     3845/**
     3846 * Slice 2 van shaer-0j2 (FEP-7628, DRAFT): de UITGAANDE helft — deze Klonkt
     3847 * is het oude huis en kondigt het vertrek aan. Twee eisen voordat er iets
     3848 * de deur uit gaat:
     3849 *  1. Geen guardians: een warded account verhuizen zonder de guardianship
     3850 *     te hertargeten zou het vangnet van het kind stil breken; dat is
     3851 *     shaer-tge's gated beslissing, dus tot die er is weigert een bewaakt
     3852 *     account de verhuizing.
     3853 *  2. De NIEUWE actor claimt ons in alsoKnownAs — dezelfde back-reference
     3854 *     die elke ontvangende server (onze eigen slice 1 incluis) eist. Zonder
     3855 *     die claim is de Move overal dood bij aankomst.
     3856 * De Move gaat duurzaam naar elke volger-inbox; hun servers doen de
     3857 * re-follow. `moved_to` wordt hier vastgelegd; het SERVEREN ervan op de
     3858 * actor (en het beleid van de oude site) is slice 3.
     3859 * Deps injecteerbaar voor tests (geen netwerk in node:test).
     3860 */
     3861export async function moveAccount(site, targetRaw, { fetchActorFn = null, deliverFn = null } = {}) {
     3862  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3863  if (!base || !site || !site.slug) return { error: 'config' };
     3864  try {
     3865    const guardians = Guardianship.listGuardians(site.slug);
     3866    if (guardians.length) {
     3867      console.warn('[AP] move refused: guarded account (shaer-tge):', site.slug, '→', String(targetRaw || ''));
     3868      return { error: 'guarded_account' };
     3869    }
     3870  } catch { /* geen guardianship-tabellen = geen guardians */ }
     3871  const s = String(targetRaw || '').trim();
     3872  let targetUri = null;
     3873  if (/^https?:\/\//i.test(s)) targetUri = safeUrl(s);
     3874  else if (s.includes('@')) targetUri = await webfingerResolve(s);
     3875  if (!targetUri) return { error: 'not_found' };
     3876  const me = actorId(base, site.slug);
     3877  if (targetUri === me) return { error: 'self' };
     3878  const target = await (fetchActorFn ? fetchActorFn(targetUri) : signedGetJson(site.slug, targetUri));
     3879  if (!target || !target.id || !target.inbox) return { error: 'unreachable' };
     3880  const aka = [].concat(target.alsoKnownAs || [])
     3881    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
     3882  if (!aka.includes(me)) return { error: 'no_backreference' };
     3883  db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(target.id, site.slug);
     3884  const keys = getOrCreateKeys(site.slug);
     3885  const move = {
     3886    '@context': AP_CONTEXT,
     3887    id: `${me}#move-${Date.now()}-${rid()}`,
     3888    type: 'Move',
     3889    actor: me,
     3890    object: me,
     3891    target: target.id,
     3892    to: [`${me}/followers`],
     3893  };
     3894  const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
     3895  for (const inbox of inboxes) {
     3896    await (deliverFn || deliverWithRetry)(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
     3897  }
     3898  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
     3899  return { ok: true, target: target.id, inboxes: inboxes.length };
     3900}
     3901
    38453902// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
    38463903/**
     
    43594416  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
    43604417  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    4361   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     4418  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    43624419  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
    43634420  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
  • src/services/i18n.js

    rf434294 r0ca7e9a4  
    253253    'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.',
    254254    'asite.aliases': 'Fediverse-aliassen',
     255    'asite.move': 'Verhuizen (fediverse)',
     256    'asite.move_hint': 'Kondig aan je volgers aan dat dit account ergens anders verder gaat. Het nieuwe profiel moet dit adres eerst als alias claimen; volgers verhuizen dan vanzelf mee. Een account met guardians kan nog niet verhuizen.',
     257    'asite.move_confirm': 'Weet je het zeker? Je volgers krijgen te horen dat dit account is verhuisd.',
     258    'asite.move_btn': 'Kondig verhuizing aan',
     259    'asite.moved_to': 'Verhuisd naar',
    255260    '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.',
    256261    'asite.link_add': '+ Link toevoegen',
     
    11911196    'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.',
    11921197    'asite.aliases': 'Fediverse aliases',
     1198    'asite.move': 'Move (fediverse)',
     1199    'asite.move_hint': 'Announce to your followers that this account continues elsewhere. The new profile must claim this address as an alias first; followers then move along automatically. An account with guardians cannot move yet.',
     1200    'asite.move_confirm': 'Are you sure? Your followers will be told this account has moved.',
     1201    'asite.move_btn': 'Announce move',
     1202    'asite.moved_to': 'Moved to',
    11931203    '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.',
    11941204    'asite.link_add': '+ Add link',
     
    21282138    'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.',
    21292139    'asite.aliases': 'Fediverse-Aliasse',
     2140    'asite.move': 'Umzug (Fediverse)',
     2141    'asite.move_hint': 'Kündige deinen Followern an, dass dieses Konto woanders weitergeht. Das neue Profil muss diese Adresse zuerst als Alias beanspruchen; Follower ziehen dann automatisch mit. Ein Konto mit Guardians kann noch nicht umziehen.',
     2142    'asite.move_confirm': 'Bist du sicher? Deine Follower erfahren, dass dieses Konto umgezogen ist.',
     2143    'asite.move_btn': 'Umzug ankündigen',
     2144    'asite.moved_to': 'Umgezogen nach',
    21302145    '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.',
    21312146    'asite.link_add': '+ Link hinzufügen',
  • src/views/pages/admin-site-edit.ejs

    rf434294 r0ca7e9a4  
    270270    <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
    271271  </form>
     272
     273<% if (!isNew) { %>
     274  <form method="post" action="/admin/sites/<%= site.slug %>/move" class="admin-form"
     275        onsubmit="return confirm('<%= t('asite.move_confirm') %>');">
     276    <fieldset>
     277      <legend><%= t('asite.move') %></legend>
     278      <p class="form-hint"><%= t('asite.move_hint') %></p>
     279      <% if (typeof site.moved_to === 'string' && site.moved_to) { %>
     280        <p class="form-hint"><strong><%= t('asite.moved_to') %>:</strong> <%= site.moved_to %></p>
     281      <% } %>
     282      <label>
     283        <input type="text" name="move_target" placeholder="@nieuw@andere-server.example" required>
     284      </label>
     285      <button type="submit" class="btn btn-danger"><%= t('asite.move_btn') %></button>
     286    </fieldset>
     287  </form>
     288<% } %>
    272289</div>
    273290
  • test/move-actor.test.js

    rf434294 r0ca7e9a4  
    124124  assert.equal('alsoKnownAs' in buildActor('https://test.example', site), false);
    125125});
     126
     127// ── Slice 2: the OUTGOING half ─────────────────────────────────────
     128// This Klonkt as the old home. A guarded account refuses (shaer-tge), a
     129// target without the alsoKnownAs back-reference refuses, and the happy path
     130// records moved_to and delivers ONE Move to every follower inbox.
     131const { moveAccount } = await import('../src/services/ActivityPubService.js');
     132const ME_RADIO = 'https://test.example/ap/users/radio';
     133const radioSite = () => db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
     134
     135test('a guarded account refuses to move (shaer-tge)', async () => {
     136  db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id)
     137              VALUES ('blog', 'ward', 'https://oma.example/u/oma', 'accepted', 'o9')`).run();
     138  const blogSite = db.prepare("SELECT * FROM sites WHERE slug = 'blog'").get();
     139  const r = await moveAccount(blogSite, 'https://elders.example/users/nieuw', {
     140    fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: ['https://test.example/ap/users/blog'] }),
     141  });
     142  assert.equal(r.error, 'guarded_account');
     143  assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'blog'").get().moved_to, null, 'nothing recorded');
     144});
     145
     146test('without the back-reference the move refuses', async () => {
     147  const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', {
     148    fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: ['https://iemand-anders.example/x'] }),
     149  });
     150  assert.equal(r.error, 'no_backreference');
     151  assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, null);
     152});
     153
     154test('the happy path: moved_to recorded, one Move to every follower inbox', async () => {
     155  db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES ('radio', 'https://a.example/u/a', 'https://a.example/inbox')").run();
     156  db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox, shared_inbox) VALUES ('radio', 'https://b.example/u/b', 'https://b.example/inbox', 'https://b.example/shared')").run();
     157  const delivered = [];
     158  const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', {
     159    fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: [ME_RADIO] }),
     160    deliverFn: async (slug, inbox, activity) => { delivered.push({ inbox, activity }); },
     161  });
     162  assert.equal(r.ok, true);
     163  assert.equal(r.target, 'https://elders.example/users/nieuw');
     164  assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, 'https://elders.example/users/nieuw');
     165  assert.equal(delivered.length, 2, 'both follower inboxes');
     166  for (const d of delivered) {
     167    assert.equal(d.activity.type, 'Move');
     168    assert.equal(d.activity.object, ME_RADIO);
     169    assert.equal(d.activity.target, 'https://elders.example/users/nieuw');
     170  }
     171  assert.ok(delivered.some((d) => d.inbox === 'https://b.example/shared'), 'shared inbox preferred');
     172});
Note: See TracChangeset for help on using the changeset viewer.