Changeset fa33214 in Klonkt
- Timestamp:
- 08/04/2026 12:55:55 PM (5 weeks ago)
- Branches:
- main
- Children:
- 04aca12, 12bed59
- Parents:
- 3d882bd
- Files:
-
- 2 added
- 9 edited
-
docs/ward-outbound-follows-design.md (modified) (1 diff)
-
src/config/database.js (modified) (2 diffs)
-
src/routes/activitypub.js (modified) (2 diffs)
-
src/routes/guardian.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
src/services/guardianship/index.js (modified) (1 diff)
-
src/services/guardianship/outgoing.js (added)
-
src/services/guardianship/queues.js (modified) (3 diffs)
-
src/services/guardianship/relations.js (modified) (1 diff)
-
test/activitypub-as2.test.js (modified) (1 diff)
-
test/outgoing-follow-gate.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
docs/ward-outbound-follows-design.md
r3d882bd rfa33214 89 89 toestemming" kan laten zien in plaats van een tegel die er al volgend uitziet. 90 90 91 ## Status 92 93 Gebouwd op 3-8-2026: `ap_pending_outgoing_follows` + `outgoing.js`, de poort in 94 `ingestOutboxActivity` (`case 'Follow'`), de `outgoingFollows`-wachtrij, en het 95 antwoord van de guardian op `POST /api/outgoing-follow/:id`. Negen tests in 96 `test/outgoing-follow-gate.test.js`. 97 91 98 ## Open vragen 92 99 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. 100 1. **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. 99 107 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. 108 2. **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. 102 112 103 3. ** De guardian zelf als doel.** Een ward die zijn eigen guardian volgt, hoort104 niet te hoeven wachten. Dat is dezelfde uitzondering als inkomend, waar de105 Follow van een vastgelegde guardian de poort overslaat.113 3. **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. 106 116 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. 117 4. **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. 109 120 110 5. ** Emancipatie.** Wat gebeurt er met openstaande verzoeken als de121 5. **Nog open. Emancipatie.** Wat gebeurt er met openstaande verzoeken als de 111 122 guardianship eindigt? Automatisch goedkeuren of laten vervallen. 123 124 6. **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. 112 129 113 130 ## Raakvlakken met andere beads -
src/config/database.js
r3d882bd rfa33214 58 58 )`); 59 59 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 ( 60 85 follow_id TEXT NOT NULL, 61 86 guardian_uri TEXT NOT NULL, … … 693 718 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms) 694 719 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 } 695 734 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them 696 735 // 30-7: C2S posts briefly got their content media copied onto the cover, -
src/routes/activitypub.js
r3d882bd rfa33214 254 254 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me)); 255 255 queueRoute('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. 259 queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me)); 256 260 queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug)); 257 261 // Availability (FEP-633c 3.6.1) is never public: the ward reads its … … 696 700 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs. 697 701 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 } : {}) }); 699 705 }); 700 706 -
src/routes/guardian.js
r3d882bd rfa33214 278 278 }); 279 279 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. 284 router.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 280 307 // ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a 281 308 // guardian to a ward. A private direct note, never a feed post. Warmth -
src/services/ActivityPubService.js
r3d882bd rfa33214 2487 2487 const actorUri = c2sIdOf(object); 2488 2488 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 } 2489 2500 // The error REACHES the app (Robins melding, 31-7): swallowing it 2490 2501 // made a failed follow look exactly like a successful one. … … 4024 4035 // Accept to the follower and record them, so delivery (incl. followers-only) 4025 4036 // 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 */ 4056 export 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 */ 4113 export 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 4026 4122 export async function acceptGatedFollow(pending) { 4027 4123 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); … … 4030 4126 const keys = getOrCreateKeys(slug); 4031 4127 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); 4032 4132 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me }; 4033 4133 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original }; … … 4512 4612 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll, 4513 4613 acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision, 4614 gateOutgoingFollow, performApprovedFollow, 4514 4615 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, 4515 4616 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, -
src/services/guardianship/index.js
r3d882bd rfa33214 19 19 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js'; 20 20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js'; 21 export { offersCollection, followsCollection, wardsCollection, guardiansCollection } from './queues.js';21 export { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection } from './queues.js'; 22 22 export * as availability from './availability.js'; 23 23 export { wireAvailability } from './availability.js'; 24 24 export * as follows from './follows.js'; 25 export * as outgoing from './outgoing.js'; 25 26 export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js'; 26 27 export { -
src/services/guardianship/queues.js
r3d882bd rfa33214 12 12 import * as relations from './relations.js'; 13 13 import * as availability from './availability.js'; 14 import * as outgoing from './outgoing.js'; 14 15 import * as handshake from './handshake.js'; 15 16 … … 39 40 } 40 41 42 /** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */ 43 export function outgoingFollowsCollection(id, slug, me) { 44 return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me))); 45 } 46 41 47 /** The guardian's committed wards, with cached handle for display. */ 42 48 export function wardsCollection(id, slug) { … … 53 59 } 54 60 55 export default { offersCollection, followsCollection, wardsCollection, guardiansCollection };61 export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection }; -
src/services/guardianship/relations.js
r3d882bd rfa33214 73 73 offers: `${id}/queues/offers`, 74 74 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`, 75 80 wards: `${id}/queues/wards`, 76 81 guardians: `${id}/queues/guardians`, -
test/activitypub-as2.test.js
r3d882bd rfa33214 35 35 // sub-keys are the daemon-contract collection names the Shaer clients read. 36 36 // `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', 38 40 // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S. 39 41 'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)