Changeset 2b4252c in Klonkt


Ignore:
Timestamp:
07/25/2026 09:15:34 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
4f15f67
Parents:
af5b79b
Message:

Cross-instance follow-goedkeuring, gemodelleerd op de guardian-offer (§5.3)

Guardians die op een andere instance wonen kunnen nu een gated follow goedkeuren.
Zelfde gedistribueerde patroon als de adoptie-offer: de ward forwardt de follow
naar z'n guardians als een Offer(Follow); elke guardian houdt een kopie en ziet
'm in /guardian2; de guardian antwoordt met Accept/Reject naar de ward; de ward
telt quorum (bestaande follows.decide) en stuurt de gewone Accept(Follow) naar de
volger. Lokale guardians blijven zoals ze waren (push + lokale beslissing, geen
forwarding). Alles achter de shaer:followApproval-marker, dus normaal verkeer
onaangeroerd.

Changed files:
src/services/ActivityPubService.js

  • gate: remote guardian krijgt Offer(Follow) (leg 1); lokale guardian push
  • handleFollowApprovalInbox: Offer(Follow) -> review (leg 2), Accept/Reject -> decide+commit (leg 4)
  • sendFollowDecision: guardian stuurt beslissing naar de ward (leg 3)
  • dispatch-tak op shaer:followApproval vóór de handshake-dispatch

src/services/guardianship/follows.js

  • guardian-side review-store (recordReview/getReview/listReviews/removeReview)

src/config/database.js

  • ap_follow_reviews (PK slug,id): de guardian-kopie van een remote-ward-follow

src/routes/guardian2.js

  • follow-requests toont ook reviews (remote); approve op een review = sendFollowDecision

test/follow-gating.test.js

  • review-store test erbij

remarks: npm test 171/171. Spec-clausule §5.3 toegevoegd (forwarding gemodelleerd
op §3). Lokale/co-located flow ongewijzigd.

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

Files:
5 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    raf5b79b r2b4252c  
    7171    PRIMARY KEY (follow_id, guardian_uri)
    7272  )`);
     73  // Cross-instance follow-approval (modelled on the guardian offer): the
     74  // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
     75  // the ward's server as an Offer(Follow). The decision is sent back to the
     76  // ward's inbox. (Local wards use ap_pending_follows directly.)
     77  db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
     78    id TEXT NOT NULL,
     79    guardian_slug TEXT NOT NULL,
     80    ward_uri TEXT NOT NULL,
     81    ward_inbox TEXT,
     82    follower_uri TEXT NOT NULL,
     83    follower_handle TEXT,
     84    follower_icon TEXT,
     85    follow_json TEXT,
     86    status TEXT DEFAULT 'pending',
     87    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
     88    PRIMARY KEY (guardian_slug, id)
     89  )`);
    7390  ensureColumn('sites', 'profile_photo', 'TEXT');
    7491  ensureColumn('audio_tracks', 'cover_url', 'TEXT');
  • src/routes/guardian2.js

    raf5b79b r2b4252c  
    148148  if (!site) return res.status(404).json({ error: 'no_site' });
    149149  const items = [];
     150  // Local wards (guardian co-located): read the pending follows directly.
    150151  for (const wardSlug of wardSlugsOf(site)) {
    151152    for (const f of Guardianship.follows.listForWard(wardSlug)) {
    152       items.push({ id: f.id, ward: wardSlug, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, created: f.created_at });
     153      items.push({ id: f.id, ward: wardSlug, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
    153154    }
     155  }
     156  // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
     157  for (const rev of Guardianship.follows.listReviews(site.slug)) {
     158    const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
     159    items.push({ id: rev.id, ward: wardName, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
    154160  }
    155161  res.json({ items });
     
    162168  const me = AP.actorId(base, site.slug);
    163169  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
     170
     171  // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
     172  // which tallies quorum and returns the Accept(Follow) to the follower.
     173  const review = Guardianship.follows.getReview(site.slug, req.params.id);
     174  if (review) {
     175    try { await AP.sendFollowDecision(site, review, decision); }
     176    catch { return res.status(502).json({ error: 'delivery' }); }
     177    Guardianship.follows.removeReview(site.slug, req.params.id);
     178    return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
     179  }
     180
     181  // Local ward: decide directly (quorum on this instance).
    164182  const pending = Guardianship.follows.getPending(req.params.id);
    165183  if (!pending) return res.status(404).json({ error: 'gone' });
    166   // I must actually be a guardian of this ward.
    167184  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
    168185  if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
  • src/services/ActivityPubService.js

    raf5b79b r2b4252c  
    13231323  }
    13241324
     1325  // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
     1326  // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
     1327  if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
     1328    if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
     1329  }
     1330
    13251331  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
    13261332  // Accept/Reject answers an offer a local guardian sent. Anything the
     
    13931399        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
    13941400      });
    1395       // The ward and its guardians live together (the family Klonkt); a push
    1396       // tells each local guardian to decide in /guardian2. Over the wire the
    1397       // follow stays a normal pending Follow until they accept (Robins besluit:
    1398       // keep the flow simple, no cross-instance forwarding).
     1401      // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
     1402      // gated follow to its guardians for approval. A LOCAL guardian gets a
     1403      // push and reads /guardian2 directly; a REMOTE guardian gets an
     1404      // Offer(Follow) delivered so its instance stores a copy (same distributed
     1405      // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
     1406      const wardActor = actorId(base, slug);
     1407      const wardKeys = getOrCreateKeys(slug);
     1408      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
    13991409      for (const g of wardGuardians) {
    14001410        const gslug = slugFromActorUrl(g);
    1401         if (!gslug) continue;
    1402         const L = pushLang(gslug);
    1403         pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
     1411        if (gslug) {
     1412          const L = pushLang(gslug);
     1413          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
     1414        } else {
     1415          fetchActor(g).then((ga) => {
     1416            const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
     1417            if (!inbox) return;
     1418            const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true };
     1419            deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
     1420          }).catch(() => {});
     1421        }
    14041422      }
    14051423      console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
     
    29542972}
    29552973
     2974// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
     2975//    offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
     2976//    2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
     2977async function handleFollowApprovalInbox(act, slugParam) {
     2978  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     2979  const type = Array.isArray(act.type) ? act.type[0] : act.type;
     2980  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     2981
     2982  // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
     2983  // signed by the ward, so act.actor is the ward.
     2984  if (type === 'Offer') {
     2985    const fo = (act.object && typeof act.object === 'object') ? act.object : null;
     2986    const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
     2987    if (!fo || foType !== 'Follow') return false;
     2988    const followId = fo.id;
     2989    const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
     2990    const wardUri = actorUri;
     2991    if (!followId || !follower || !wardUri) return false;
     2992    const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
     2993    if (slugParam) recips.push(actorId(base, slugParam));
     2994    let stored = false;
     2995    for (const r of new Set(recips)) {
     2996      const gslug = slugFromActorUrl(r);
     2997      if (!gslug) continue;
     2998      if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue;   // must actually guard this ward
     2999      const wardDoc = await fetchActor(wardUri).catch(() => null);
     3000      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
     3001      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
     3002      const L = pushLang(gslug);
     3003      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
     3004      stored = true;
     3005    }
     3006    return stored;
     3007  }
     3008
     3009  // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
     3010  const fo = act.object;
     3011  const followId = typeof fo === 'string' ? fo : (fo && fo.id);
     3012  if (!followId) return false;
     3013  const pending = Guardianship.follows.getPending(followId);
     3014  if (!pending) return false;
     3015  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
     3016  if (!guardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
     3017  const decision = type === 'Reject' ? 'reject' : 'approve';
     3018  const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
     3019  try {
     3020    if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
     3021    else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
     3022  } catch { /* delivery is retried */ }
     3023  return true;
     3024}
     3025
     3026// Leg 3: a guardian in /guardian2 decides on a forwarded follow; send the
     3027// Accept/Reject back to the ward's inbox (signed by the guardian).
     3028export async function sendFollowDecision(guardianSite, review, decision) {
     3029  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3030  const me = actorId(base, guardianSite.slug);
     3031  const keys = getOrCreateKeys(guardianSite.slug);
     3032  const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
     3033  const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true };
     3034  if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
     3035  return { ok: true };
     3036}
     3037
    29563038// Send a Like or Announce (boost) on a remote note FROM this site.
    29573039export async function sendInteraction(site, kind, targetNoteId, authorUri) {
     
    32683350  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    32693351  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
    3270   acceptGatedFollow, rejectGatedFollow, isWardGuardian,
     3352  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
    32713353  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    32723354  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
  • src/services/guardianship/follows.js

    raf5b79b r2b4252c  
    7373export function remove(id) { stmts().del.run(id); }
    7474
    75 export default { recordPending, getPending, listForWard, decide, remove };
     75// ── Guardian-side copy (cross-instance, modelled on the guardian offer): a
     76//    gated follow on a REMOTE ward this account guards, forwarded here as an
     77//    Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ──
     78let _r = null;
     79function rstmts() {
     80  if (!_r) {
     81    _r = {
     82      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
     83        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
     84        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
     85      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
     86      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
     87      del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
     88    };
     89  }
     90  return _r;
     91}
     92
     93export function recordReview(guardianSlug, r) {
     94  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
     95  return rstmts().get.get(guardianSlug, r.id);
     96}
     97export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
     98export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
     99export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
     100
     101export default {
     102  recordPending, getPending, listForWard, decide, remove,
     103  recordReview, getReview, listReviews, removeReview,
     104};
  • test/follow-gating.test.js

    raf5b79b r2b4252c  
    3333  assert.equal(r.outcome, 'rejected');
    3434});
     35
     36test('guardian-side review copy (cross-instance, modelled on the offer)', () => {
     37  // A remote ward's server forwarded an Offer(Follow); the guardian stores a copy.
     38  G.follows.recordReview('gran', { id: 'r1', wardUri: 'https://w.example/kid', wardInbox: 'https://w.example/kid/inbox', follower: STRANGER, followerHandle: '@x@m.social' });
     39  const list = G.follows.listReviews('gran');
     40  assert.equal(list.length, 1);
     41  assert.equal(list[0].ward_inbox, 'https://w.example/kid/inbox');
     42  assert.ok(G.follows.getReview('gran', 'r1'));
     43  G.follows.removeReview('gran', 'r1');
     44  assert.equal(G.follows.listReviews('gran').length, 0);
     45});
Note: See TracChangeset for help on using the changeset viewer.