Ignore:
Timestamp:
07/25/2026 07:51:28 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
e62f65d
Parents:
28267519
Message:

Guardian 2: follow-goedkeuring (FEP-633c §5.3)

Een Follow op een ward wordt niet meer automatisch geaccepteerd: hij wacht op
goedkeuring van de guardians. Afgebakend zoals de spec: gating geldt ALLEEN voor
ward-actors (die guardians hebben); een gewone site zonder guardians accepteert
als vanouds, geen gedragswijziging. Een gecommitte guardian die zelf volgt wordt
wel meteen geaccepteerd (Barts regel: die heeft geen gate nodig, en zo werkt het
meekijken). Quorum per ward: 'any' (default, één volstaat), 'all' of 'none'; een
enkele reject weigert.

De guardian ziet de verzoeken in /guardian2 (ward en guardian zitten op dezelfde
familie-Klonkt, dus lokaal leesbaar) en tikt Accept/Deny. Bij goedkeuring stuurt
Klonkt de Accept(Follow) en legt de follower vast, zodat bezorging (ook
followers-only) begint. Cross-instance federatie van de goedkeuring is een latere
verfijning (de daemon heeft het patroon).

Changed files:
src/services/ActivityPubService.js

  • inbound Follow: gate voor ward-actors; acceptGatedFollow/rejectGatedFollow

src/config/database.js

  • ap_pending_follows + ap_pending_follow_approvals

src/services/guardianship/index.js

  • follows-module geexporteerd

src/routes/guardian2.js

  • GET /api/follow-requests, POST /api/follow/:id (guardian-gecheckt)

src/views/pages/guardian2.ejs, src/assets/js/guardian2.js

src/services/i18n.js

  • guardian2 follow_title/follow_sub (nl/en/de)

New file:
src/services/guardianship/follows.js

  • de gating-store + quorum-beslissing (any/all), pure en testbaar

test/follow-gating.test.js

  • any/all-quorum en single-reject

remarks: npm test 167/167. v1 /guardian en niet-ward-sites onaangeraakt.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r28267519 r5c373b8  
    13751375    const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
    13761376    const fi = actorInfo(remote, who);   // cache display for the friends list (shaer-aa3)
     1377    // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
     1378    // follow is gated. A committed guardian's own Follow is auto-accepted
     1379    // (it needs no gate); anyone else is held pending for guardian approval.
     1380    // Free actors / normal sites have no guardians → fall through, unchanged.
     1381    const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
     1382    if (wardGuardians.length && !wardGuardians.includes(who)) {
     1383      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
     1384      Guardianship.follows.recordPending(slug, {
     1385        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
     1386        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
     1387      });
     1388      for (const g of wardGuardians) {
     1389        const gslug = slugFromActorUrl(g);
     1390        if (!gslug) continue;
     1391        const L = pushLang(gslug);
     1392        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` });
     1393      }
     1394      console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
     1395      return 202;
     1396    }
    13771397    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
    13781398    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
     
    28792899}
    28802900
     2901// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
     2902// Accept to the follower and record them, so delivery (incl. followers-only)
     2903// begins. `pending` is a row from ap_pending_follows.
     2904export async function acceptGatedFollow(pending) {
     2905  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     2906  const slug = pending.ward_slug;
     2907  const me = actorId(base, slug);
     2908  const keys = getOrCreateKeys(slug);
     2909  fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
     2910  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
     2911  const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
     2912  await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
     2913  const filled = pending.follower_shared_inbox &&
     2914    db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1').get(slug, pending.follower_shared_inbox, pending.follower_uri);
     2915  if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
     2916  console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
     2917  return { ok: true };
     2918}
     2919
     2920// The guardians denied the follow: send a Reject so the follower's server clears
     2921// its pending state, then the caller drops the record.
     2922export async function rejectGatedFollow(pending) {
     2923  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     2924  const slug = pending.ward_slug;
     2925  const me = actorId(base, slug);
     2926  const keys = getOrCreateKeys(slug);
     2927  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
     2928  const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
     2929  if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
     2930  console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
     2931  return { ok: true };
     2932}
     2933
    28812934// Send a Like or Announce (boost) on a remote note FROM this site.
    28822935export async function sendInteraction(site, kind, targetNoteId, authorUri) {
     
    31933246  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    31943247  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
     3248  acceptGatedFollow, rejectGatedFollow,
    31953249  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    31963250  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Note: See TracChangeset for help on using the changeset viewer.