Ignore:
Timestamp:
08/04/2026 12:55:55 PM (5 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
04aca12, 12bed59
Parents:
3d882bd
Message:

FEP-633c §5.3 andersom: een ward vraagt eerst of het iemand mag volgen

Uitgaande follows gingen ongehinderd de deur uit; de guardians kregen achteraf
een bericht (1a2f206). Dat is informeren, niet gaten — de deur staat al open als
het bericht aankomt. Bead shaer-p729, ontwerp in
docs/ward-outbound-follows-design.md.

De regel: per geval goedkeuring, met twee uitzonderingen die geen gunst zijn
maar dezelfde beslissing die al genomen is. Je eigen guardian volgen is geen
vraag. En iemand die de ward al volgt DOOR DE POORT heen is door een guardian
bij naam goedgekeurd; die vraag nog eens stellen leert mensen alleen om de vraag
niet meer te lezen.

Daarvoor moet je weten wie er door de poort kwam, dus ap_followers krijgt
gate_approved, gezet bij acceptGatedFollow. Iedereen die al volgde toen die
kolom erbij kwam wordt eenmalig gegrandfatherd (Barts besluit): exact vanaf nu,
in plaats van met terugwerkende kracht wantrouwig tegen wat er al was.

Eigen tabel, want ap_pending_follows is gesleuteld met de ward als DOEL. Eigen
wachtrij (outgoingFollows), want een guardian moet "iemand wil je ward volgen"
kunnen onderscheiden van "je ward wil iemand volgen" — de AS2-test ving netjes
dat de nieuwe term aangemeld moest worden. En een tegengehouden follow reist als
derde uitkomst naar de app (state: awaiting_guardian), zodat Shaer "wacht op
toestemming" kan tonen in plaats van een tegel die er al volgend uitziet.

Co-Authored-By: Claude Opus 5 <claude@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r3d882bd rfa33214  
    24872487        const actorUri = c2sIdOf(object);
    24882488        if (!actorUri) return { status: 400, error: 'missing_object' };
     2489        // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
     2490        // A held request is a THIRD outcome — not sent, not failed — and it
     2491        // travels to the app as one, so Shaer can show "waiting" instead of a
     2492        // tile that already looks followed.
     2493        const held = await gateOutgoingFollow(site, actorUri);
     2494        if (held) {
     2495          return {
     2496            status: 202, url: actorUri, id: held.id,
     2497            state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
     2498          };
     2499        }
    24892500        // The error REACHES the app (Robins melding, 31-7): swallowing it
    24902501        // made a failed follow look exactly like a successful one.
     
    40244035// Accept to the follower and record them, so delivery (incl. followers-only)
    40254036// begins. `pending` is a row from ap_pending_follows.
     4037/**
     4038 * FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
     4039 *
     4040 * A ward's OWN follow waited for nobody: it went straight out and the guardians
     4041 * got a note afterwards (1a2f206). That is informing, not gating — the door is
     4042 * already open when the message lands. Now it waits, with two exceptions that
     4043 * are not favours but the same decision already taken:
     4044 *
     4045 *   - the target is one of the ward's own guardians. Following the adult who
     4046 *     watches over you is not a question anyone needs to answer.
     4047 *   - the target already follows the ward THROUGH THE GATE. A guardian
     4048 *     approved that person by name; asking again about the same person only
     4049 *     teaches everyone to stop reading the question.
     4050 *
     4051 * Returns the held request, or null when the follow may go out now.
     4052 * Deliberately not a boolean: a held follow must be distinguishable from a sent
     4053 * one all the way up to the app, which is the lesson the error path already
     4054 * learned (Robins melding, 31-7).
     4055 */
     4056export async function gateOutgoingFollow(site, targetUri) {
     4057  const slug = site && site.slug;
     4058  if (!slug || !targetUri) return null;
     4059  const guardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
     4060  if (!guardians.length) return null;                                   // not a ward: nothing to gate
     4061  if (guardians.includes(targetUri)) return null;                       // your own guardian
     4062  if (Guardianship.outgoing.isMutual(slug, targetUri)) return null;     // already vetted by name
     4063
     4064  const seen = Guardianship.outgoing.findFor(slug, targetUri);
     4065  if (seen && seen.status === 'approved') return null;                  // the guardians said yes already
     4066  if (seen && (seen.status === 'pending' || seen.status === 'denied')) return seen;
     4067
     4068  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     4069  const wardActor = actorId(base, slug);
     4070  const target = await fetchActor(targetUri).catch(() => null);
     4071  const ti = actorInfo(target, targetUri);
     4072  const id = `${wardActor}#outfollow-${Date.now()}-${rid()}`;
     4073  const held = Guardianship.outgoing.recordPending(slug, {
     4074    id, target: targetUri,
     4075    inbox: target && ((target.endpoints && target.endpoints.sharedInbox) || target.inbox),
     4076    name: ti.name, handle: ti.handle, icon: ti.icon,
     4077  });
     4078
     4079  // Same routing as the inbound gate: a guardian on this instance gets a push
     4080  // and reads /guardian; one elsewhere gets an Offer delivered so its own
     4081  // server holds a copy to answer from.
     4082  const wardKeys = getOrCreateKeys(slug);
     4083  const followObj = { id, type: 'Follow', actor: wardActor, object: targetUri };
     4084  for (const g of guardians) {
     4085    try { Guardianship.availability.recordRequest(slug, g, id, Date.now()); } catch { /* never load-bearing */ }
     4086  }
     4087  for (const g of guardians) {
     4088    const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
     4089    const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
     4090    if (isLocal) {
     4091      const L = pushLang(gslug);
     4092      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: ti.name || ti.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
     4093    } else {
     4094      fetchActor(g).then((ga) => {
     4095        const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
     4096        if (!inbox) return;
     4097        const offer = { '@context': AP_CONTEXT, id: `${wardActor}#outfollowoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:direction': 'outgoing' };
     4098        deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
     4099      }).catch(() => {});
     4100    }
     4101  }
     4102  console.log('[AP] outgoing Follow', slug, '→', targetUri, '(gated, awaiting guardians)');
     4103  return held || { id, ward_slug: slug, target_uri: targetUri, status: 'pending' };
     4104}
     4105
     4106/**
     4107 * The guardians said yes: send the ward's Follow for real (§5.3, shaer-p729).
     4108 *
     4109 * The row stays behind as `approved` rather than being deleted. It is the
     4110 * record that these guardians vetted this target, so an unfollow-and-refollow
     4111 * later does not put the same question in front of them again.
     4112 */
     4113export async function performApprovedFollow(pending) {
     4114  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(pending.ward_slug);
     4115  if (!site) return { error: 'no_such_ward' };
     4116  const r = await followActor(site, pending.target_uri);
     4117  if (r && r.error) return { error: r.error };
     4118  console.log('[AP] outgoing Follow approved', pending.ward_slug, '→', pending.target_uri);
     4119  return { ok: true };
     4120}
     4121
    40264122export async function acceptGatedFollow(pending) {
    40274123  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     
    40304126  const keys = getOrCreateKeys(slug);
    40314127  fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
     4128  // This follower came through the §5.3 gate: a guardian said yes to this
     4129  // person by name. That is precisely what lets the ward follow them back later
     4130  // without asking the same guardians the same question twice (shaer-p729).
     4131  db.prepare('UPDATE ap_followers SET gate_approved = 1 WHERE slug = ? AND actor_uri = ?').run(slug, pending.follower_uri);
    40324132  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
    40334133  const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
     
    45124612  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    45134613  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
     4614  gateOutgoingFollow, performApprovedFollow,
    45144615  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    45154616  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Note: See TracChangeset for help on using the changeset viewer.