Changeset 2b4252c in Klonkt
- Timestamp:
- 07/25/2026 09:15:34 AM (7 weeks ago)
- Branches:
- main
- Children:
- 4f15f67
- Parents:
- af5b79b
- Files:
-
- 5 edited
-
src/config/database.js (modified) (1 diff)
-
src/routes/guardian2.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
src/services/guardianship/follows.js (modified) (1 diff)
-
test/follow-gating.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
raf5b79b r2b4252c 71 71 PRIMARY KEY (follow_id, guardian_uri) 72 72 )`); 73 // Cross-instance follow-approval (modelled on the guardian offer): the 74 // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by 75 // the ward's server as an Offer(Follow). The decision is sent back to the 76 // ward's inbox. (Local wards use ap_pending_follows directly.) 77 db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews ( 78 id TEXT NOT NULL, 79 guardian_slug TEXT NOT NULL, 80 ward_uri TEXT NOT NULL, 81 ward_inbox TEXT, 82 follower_uri TEXT NOT NULL, 83 follower_handle TEXT, 84 follower_icon TEXT, 85 follow_json TEXT, 86 status TEXT DEFAULT 'pending', 87 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 88 PRIMARY KEY (guardian_slug, id) 89 )`); 73 90 ensureColumn('sites', 'profile_photo', 'TEXT'); 74 91 ensureColumn('audio_tracks', 'cover_url', 'TEXT'); -
src/routes/guardian2.js
raf5b79b r2b4252c 148 148 if (!site) return res.status(404).json({ error: 'no_site' }); 149 149 const items = []; 150 // Local wards (guardian co-located): read the pending follows directly. 150 151 for (const wardSlug of wardSlugsOf(site)) { 151 152 for (const f of Guardianship.follows.listForWard(wardSlug)) { 152 items.push({ id: f.id, ward: wardSlug, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, created: f.created_at });153 items.push({ id: f.id, ward: wardSlug, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at }); 153 154 } 155 } 156 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance). 157 for (const rev of Guardianship.follows.listReviews(site.slug)) { 158 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })(); 159 items.push({ id: rev.id, ward: wardName, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at }); 154 160 } 155 161 res.json({ items }); … … 162 168 const me = AP.actorId(base, site.slug); 163 169 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve'; 170 171 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward, 172 // which tallies quorum and returns the Accept(Follow) to the follower. 173 const review = Guardianship.follows.getReview(site.slug, req.params.id); 174 if (review) { 175 try { await AP.sendFollowDecision(site, review, decision); } 176 catch { return res.status(502).json({ error: 'delivery' }); } 177 Guardianship.follows.removeReview(site.slug, req.params.id); 178 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' }); 179 } 180 181 // Local ward: decide directly (quorum on this instance). 164 182 const pending = Guardianship.follows.getPending(req.params.id); 165 183 if (!pending) return res.status(404).json({ error: 'gone' }); 166 // I must actually be a guardian of this ward.167 184 const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri); 168 185 if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' }); -
src/services/ActivityPubService.js
raf5b79b r2b4252c 1323 1323 } 1324 1324 1325 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to 1326 // the guardians as an Offer(Follow), their Accept/Reject back to the ward. 1327 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) { 1328 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; } 1329 } 1330 1325 1331 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an 1326 1332 // Accept/Reject answers an offer a local guardian sent. Anything the … … 1393 1399 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, 1394 1400 }); 1395 // The ward and its guardians live together (the family Klonkt); a push 1396 // tells each local guardian to decide in /guardian2. Over the wire the 1397 // follow stays a normal pending Follow until they accept (Robins besluit: 1398 // keep the flow simple, no cross-instance forwarding). 1401 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the 1402 // gated follow to its guardians for approval. A LOCAL guardian gets a 1403 // push and reads /guardian2 directly; a REMOTE guardian gets an 1404 // Offer(Follow) delivered so its instance stores a copy (same distributed 1405 // pattern as the adoption offer). On quorum the ward returns Accept(Follow). 1406 const wardActor = actorId(base, slug); 1407 const wardKeys = getOrCreateKeys(slug); 1408 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor }; 1399 1409 for (const g of wardGuardians) { 1400 1410 const gslug = slugFromActorUrl(g); 1401 if (!gslug) continue; 1402 const L = pushLang(gslug); 1403 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` }); 1411 if (gslug) { 1412 const L = pushLang(gslug); 1413 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` }); 1414 } else { 1415 fetchActor(g).then((ga) => { 1416 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox); 1417 if (!inbox) return; 1418 const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true }; 1419 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {}); 1420 }).catch(() => {}); 1421 } 1404 1422 } 1405 1423 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)'); … … 2954 2972 } 2955 2973 2974 // ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian 2975 // offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg 2976 // 2), or a guardian's Accept/Reject coming back to the ward (leg 4). ────── 2977 async function handleFollowApprovalInbox(act, slugParam) { 2978 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 2979 const type = Array.isArray(act.type) ? act.type[0] : act.type; 2980 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id); 2981 2982 // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is 2983 // signed by the ward, so act.actor is the ward. 2984 if (type === 'Offer') { 2985 const fo = (act.object && typeof act.object === 'object') ? act.object : null; 2986 const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type); 2987 if (!fo || foType !== 'Follow') return false; 2988 const followId = fo.id; 2989 const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id); 2990 const wardUri = actorUri; 2991 if (!followId || !follower || !wardUri) return false; 2992 const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string'); 2993 if (slugParam) recips.push(actorId(base, slugParam)); 2994 let stored = false; 2995 for (const r of new Set(recips)) { 2996 const gslug = slugFromActorUrl(r); 2997 if (!gslug) continue; 2998 if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue; // must actually guard this ward 2999 const wardDoc = await fetchActor(wardUri).catch(() => null); 3000 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower); 3001 Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) }); 3002 const L = pushLang(gslug); 3003 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` }); 3004 stored = true; 3005 } 3006 return stored; 3007 } 3008 3009 // Leg 4: I am the ward; a guardian decided. object is the Follow (id). 3010 const fo = act.object; 3011 const followId = typeof fo === 'string' ? fo : (fo && fo.id); 3012 if (!followId) return false; 3013 const pending = Guardianship.follows.getPending(followId); 3014 if (!pending) return false; 3015 const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri); 3016 if (!guardians.includes(actorUri)) return false; // only a real guardian of this ward decides 3017 const decision = type === 'Reject' ? 'reject' : 'approve'; 3018 const r = Guardianship.follows.decide(followId, actorUri, decision, guardians); 3019 try { 3020 if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); } 3021 else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); } 3022 } catch { /* delivery is retried */ } 3023 return true; 3024 } 3025 3026 // Leg 3: a guardian in /guardian2 decides on a forwarded follow; send the 3027 // Accept/Reject back to the ward's inbox (signed by the guardian). 3028 export async function sendFollowDecision(guardianSite, review, decision) { 3029 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 3030 const me = actorId(base, guardianSite.slug); 3031 const keys = getOrCreateKeys(guardianSite.slug); 3032 const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri }; 3033 const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true }; 3034 if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem); 3035 return { ok: true }; 3036 } 3037 2956 3038 // Send a Like or Announce (boost) on a remote note FROM this site. 2957 3039 export async function sendInteraction(site, kind, targetNoteId, authorUri) { … … 3268 3350 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 3269 3351 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll, 3270 acceptGatedFollow, rejectGatedFollow, isWardGuardian, 3352 acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision, 3271 3353 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, 3272 3354 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, -
src/services/guardianship/follows.js
raf5b79b r2b4252c 73 73 export function remove(id) { stmts().del.run(id); } 74 74 75 export default { recordPending, getPending, listForWard, decide, remove }; 75 // ── Guardian-side copy (cross-instance, modelled on the guardian offer): a 76 // gated follow on a REMOTE ward this account guards, forwarded here as an 77 // Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ── 78 let _r = null; 79 function rstmts() { 80 if (!_r) { 81 _r = { 82 ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews 83 (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at) 84 VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`), 85 get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'), 86 bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"), 87 del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'), 88 }; 89 } 90 return _r; 91 } 92 93 export function recordReview(guardianSlug, r) { 94 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null); 95 return rstmts().get.get(guardianSlug, r.id); 96 } 97 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); } 98 export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); } 99 export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); } 100 101 export default { 102 recordPending, getPending, listForWard, decide, remove, 103 recordReview, getReview, listReviews, removeReview, 104 }; -
test/follow-gating.test.js
raf5b79b r2b4252c 33 33 assert.equal(r.outcome, 'rejected'); 34 34 }); 35 36 test('guardian-side review copy (cross-instance, modelled on the offer)', () => { 37 // A remote ward's server forwarded an Offer(Follow); the guardian stores a copy. 38 G.follows.recordReview('gran', { id: 'r1', wardUri: 'https://w.example/kid', wardInbox: 'https://w.example/kid/inbox', follower: STRANGER, followerHandle: '@x@m.social' }); 39 const list = G.follows.listReviews('gran'); 40 assert.equal(list.length, 1); 41 assert.equal(list[0].ward_inbox, 'https://w.example/kid/inbox'); 42 assert.ok(G.follows.getReview('gran', 'r1')); 43 G.follows.removeReview('gran', 'r1'); 44 assert.equal(G.follows.listReviews('gran').length, 0); 45 });
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)