Changeset fa33214 in Klonkt


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

Files:
2 added
9 edited

Legend:

Unmodified
Added
Removed
  • docs/ward-outbound-follows-design.md

    r3d882bd rfa33214  
    8989  toestemming" kan laten zien in plaats van een tegel die er al volgend uitziet.
    9090
     91## Status
     92
     93Gebouwd op 3-8-2026: `ap_pending_outgoing_follows` + `outgoing.js`, de poort in
     94`ingestOutboxActivity` (`case 'Follow'`), de `outgoingFollows`-wachtrij, en het
     95antwoord van de guardian op `POST /api/outgoing-follow/:id`. Negen tests in
     96`test/outgoing-follow-gate.test.js`.
     97
    9198## Open vragen
    9299
    93 1. **Wat doen we met followers van vóór de adoptie?** Was de ward eerst een vrije
    94    actor, dan zijn zijn bestaande followers nooit door een guardian gezien. Bij de
    95    regel hierboven worden dat stuk voor stuk automatisch goed te keuren doelen.
    96    Ofwel we accepteren dat, ofwel `ap_followers` krijgt een markering "door de
    97    poort gekomen" en alleen die telt mee. Dit is de belangrijkste vraag van dit
    98    document.
     1001. **Beantwoord (Bart, 3-8): bestaande followers worden gegrandfatherd.**
     101   `ap_followers` heeft nu `gate_approved`, gezet zodra de inkomende poort
     102   iemand toelaat. Iedereen die al volgde op het moment dat de kolom erbij kwam,
     103   krijgt de markering eenmalig mee: de regel is exact vanaf dat moment, in
     104   plaats van met terugwerkende kracht wantrouwig tegen relaties die er al
     105   waren. Wie daarna binnenkomt zonder poort — de followers van een vrije actor
     106   die later ward wordt — telt niet mee voor de wederkerigheid.
    99107
    100 2. **Mag de ward zijn eigen verzoek intrekken** zolang het in de wacht staat? Lijkt
    101    vanzelfsprekend ja, maar het is een Undo op iets dat nooit verstuurd is.
     1082. **Nog open. Mag de ward zijn eigen verzoek intrekken** zolang het in de wacht
     109   staat? `outgoing.withdraw()` bestaat al, maar er is nog geen route en geen
     110   knop. Lijkt vanzelfsprekend ja, maar het is een Undo op iets dat nooit
     111   verstuurd is.
    102112
    103 3. **De guardian zelf als doel.** Een ward die zijn eigen guardian volgt, hoort
    104    niet te hoeven wachten. Dat is dezelfde uitzondering als inkomend, waar de
    105    Follow van een vastgelegde guardian de poort overslaat.
     1133. **Beantwoord: de guardian zelf als doel wacht niet.** Dezelfde uitzondering
     114   als inkomend, waar de Follow van een vastgelegde guardian de poort overslaat.
     115   Gebouwd en getest.
    106116
    107 4. **Interactie met Block/Orbit.** Blokkeert de ward iemand terwijl er nog een
    108    verzoek voor die persoon open staat, dan moet dat verzoek verdwijnen.
     1174. **Nog open. Interactie met Block/Orbit.** Blokkeert de ward iemand terwijl er
     118   nog een verzoek voor die persoon open staat, dan moet dat verzoek verdwijnen.
     119   `withdraw()` is er klaar voor; het wordt alleen nog nergens aangeroepen.
    109120
    110 5. **Emancipatie.** Wat gebeurt er met openstaande verzoeken als de
     1215. **Nog open. Emancipatie.** Wat gebeurt er met openstaande verzoeken als de
    111122   guardianship eindigt? Automatisch goedkeuren of laten vervallen.
     123
     1246. **Nieuw, uit het bouwen. Er zit geen venster op een verzoek.** Een uitgaande
     125   follow die niemand beantwoordt blijft staan tot iemand hem beantwoordt —
     126   dezelfde omissie die de handshake had voordat er een week op kwam (§3.5). Een
     127   kind dat vraagt of het iemand mag volgen en nooit antwoord krijgt, verdient
     128   een afloop.
    112129
    113130## Raakvlakken met andere beads
  • src/config/database.js

    r3d882bd rfa33214  
    5858  )`);
    5959  db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follow_approvals (
     60    follow_id TEXT NOT NULL,
     61    guardian_uri TEXT NOT NULL,
     62    decision TEXT NOT NULL,
     63    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
     64    PRIMARY KEY (follow_id, guardian_uri)
     65  )`);
     66  // FEP-633c §5.3, the OTHER direction (shaer-p729): a ward's own follow is
     67  // held until its guardians approve. Deliberately not ap_pending_follows —
     68  // that table is keyed with the ward as the TARGET ("who wants to follow me"),
     69  // and adding a direction column would make every existing query ambiguous.
     70  db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_outgoing_follows (
     71    id TEXT PRIMARY KEY,
     72    ward_slug TEXT NOT NULL,
     73    target_uri TEXT NOT NULL,
     74    target_inbox TEXT,
     75    target_name TEXT,
     76    target_handle TEXT,
     77    target_icon TEXT,
     78    quorum TEXT DEFAULT 'any',
     79    status TEXT DEFAULT 'pending',
     80    created_at TEXT DEFAULT CURRENT_TIMESTAMP
     81  )`);
     82  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ap_outgoing_follows_target
     83           ON ap_pending_outgoing_follows(ward_slug, target_uri)`);
     84  db.exec(`CREATE TABLE IF NOT EXISTS ap_outgoing_follow_approvals (
    6085    follow_id TEXT NOT NULL,
    6186    guardian_uri TEXT NOT NULL,
     
    693718  ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
    694719  ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
     720  // Did a guardian actually say yes to this follower? That is what makes the
     721  // mutual shortcut sound: a ward may follow back anyone its guardians already
     722  // admitted, without asking the same question twice. Only follows that came
     723  // through the §5.3 gate carry the mark; a free actor's followers never faced
     724  // one. Everyone already following when this column arrives is grandfathered
     725  // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
     726  // than retroactively suspicious of relationships that already exist.
     727  {
     728    const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
     729    ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
     730    if (!had || !had.n) {
     731      try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
     732    }
     733  }
    695734  ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
    696735  // 30-7: C2S posts briefly got their content media copied onto the cover,
  • src/routes/activitypub.js

    r3d882bd rfa33214  
    254254queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
    255255queueRoute('follows', (id) => Guardianship.followsCollection(id));
     256// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
     257// waiting on its guardians. Owner-only like the rest — who a child wants to
     258// follow is nobody else's business.
     259queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
    256260queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
    257261// Availability (FEP-633c 3.6.1) is never public: the ward reads its
     
    696700  // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
    697701  if (out.status === 201 && out.url) res.set('Location', out.url);
    698   return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
     702  // `state` carries a third outcome the app must be able to tell apart from a
     703  // plain success: a ward's follow held for its guardians (§5.3, shaer-p729).
     704  return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url, ...(out.state ? { state: out.state } : {}) });
    699705});
    700706
  • src/routes/guardian.js

    r3d882bd rfa33214  
    278278});
    279279
     280// ── §5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
     281//    and the guardians decide. Same quorum arithmetic and the same availability
     282//    rules as the inbound gate above; only the question is turned around, which
     283//    is why it gets its own endpoint rather than a flag on that one.
     284router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
     285  const site = siteForUser(req);
     286  if (!site) return res.status(404).json({ error: 'no_site' });
     287  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     288  const me = AP.actorId(base, site.slug);
     289  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
     290
     291  const pending = Guardianship.outgoing.getPending(req.params.id);
     292  if (!pending) return res.status(404).json({ error: 'gone' });
     293  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
     294  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
     295  Guardianship.availability.oneAnswer(me, Date.now());
     296  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
     297  const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
     298  try {
     299    // Only on approval does anything leave the building. A refusal is a local
     300    // fact: the follow was never sent, so there is nothing out there to undo
     301    // and nobody to inform that a child asked about them.
     302    if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
     303  } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
     304  res.json({ ok: true, outcome: r.outcome });
     305});
     306
    280307// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
    281308//    guardian to a ward. A private direct note, never a feed post. Warmth
  • 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,
  • src/services/guardianship/index.js

    r3d882bd rfa33214  
    1919export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
    2020export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js';
    21 export { offersCollection, followsCollection, wardsCollection, guardiansCollection } from './queues.js';
     21export { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection } from './queues.js';
    2222export * as availability from './availability.js';
    2323export { wireAvailability } from './availability.js';
    2424export * as follows from './follows.js';
     25export * as outgoing from './outgoing.js';
    2526export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
    2627export {
  • src/services/guardianship/queues.js

    r3d882bd rfa33214  
    1212import * as relations from './relations.js';
    1313import * as availability from './availability.js';
     14import * as outgoing from './outgoing.js';
    1415import * as handshake from './handshake.js';
    1516
     
    3940}
    4041
     42/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
     43export function outgoingFollowsCollection(id, slug, me) {
     44  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
     45}
     46
    4147/** The guardian's committed wards, with cached handle for display. */
    4248export function wardsCollection(id, slug) {
     
    5359}
    5460
    55 export default { offersCollection, followsCollection, wardsCollection, guardiansCollection };
     61export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection };
  • src/services/guardianship/relations.js

    r3d882bd rfa33214  
    7373      offers: `${id}/queues/offers`,
    7474      follows: `${id}/queues/follows`,
     75      // Both directions of §5.3, kept apart on purpose: a guardian must be able
     76      // to tell "someone wants to follow your ward" from "your ward wants to
     77      // follow someone". Same mechanism, opposite question, different words in
     78      // the interface (shaer-p729).
     79      outgoingFollows: `${id}/queues/outgoing-follows`,
    7580      wards: `${id}/queues/wards`,
    7681      guardians: `${id}/queues/guardians`,
  • test/activitypub-as2.test.js

    r3d882bd rfa33214  
    3535  // sub-keys are the daemon-contract collection names the Shaer clients read.
    3636  // `guardians` is the availability queue (3.6.1: never public, owner-only).
    37   'shaer:queues', 'offers', 'follows', 'wards', 'guardians',
     37  // `outgoingFollows` is §5.3 turned around: the ward's own follow requests,
     38  // waiting for the guardians (shaer-p729).
     39  'shaer:queues', 'offers', 'follows', 'outgoingFollows', 'wards', 'guardians',
    3840  // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
    3941  'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
Note: See TracChangeset for help on using the changeset viewer.