Changeset fa33214 in Klonkt for src


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

Location:
src
Files:
1 added
7 edited

Legend:

Unmodified
Added
Removed
  • 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`,
Note: See TracChangeset for help on using the changeset viewer.