source: Klonkt/src/services/guardianship/follows.js@ 2b4252c

main
Last change on this file since 2b4252c was 2b4252c, checked in by Robin <roboburr@…>, 7 weeks ago

Cross-instance follow-goedkeuring, gemodelleerd op de guardian-offer (§5.3)

Guardians die op een andere instance wonen kunnen nu een gated follow goedkeuren.
Zelfde gedistribueerde patroon als de adoptie-offer: de ward forwardt de follow
naar z'n guardians als een Offer(Follow); elke guardian houdt een kopie en ziet
'm in /guardian2; de guardian antwoordt met Accept/Reject naar de ward; de ward
telt quorum (bestaande follows.decide) en stuurt de gewone Accept(Follow) naar de
volger. Lokale guardians blijven zoals ze waren (push + lokale beslissing, geen
forwarding). Alles achter de shaer:followApproval-marker, dus normaal verkeer
onaangeroerd.

Changed files:
src/services/ActivityPubService.js

  • gate: remote guardian krijgt Offer(Follow) (leg 1); lokale guardian push
  • handleFollowApprovalInbox: Offer(Follow) -> review (leg 2), Accept/Reject -> decide+commit (leg 4)
  • sendFollowDecision: guardian stuurt beslissing naar de ward (leg 3)
  • dispatch-tak op shaer:followApproval vóór de handshake-dispatch

src/services/guardianship/follows.js

  • guardian-side review-store (recordReview/getReview/listReviews/removeReview)

src/config/database.js

  • ap_follow_reviews (PK slug,id): de guardian-kopie van een remote-ward-follow

src/routes/guardian2.js

  • follow-requests toont ook reviews (remote); approve op een review = sendFollowDecision

test/follow-gating.test.js

  • review-store test erbij

remarks: npm test 171/171. Spec-clausule §5.3 toegevoegd (forwarding gemodelleerd
op §3). Lokale/co-located flow ongewijzigd.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 4.8 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.3) — follow-gating for wards.
3 *
4 * A `Follow` targeting a ward is NOT auto-accepted. It is held pending and
5 * routed to the ward's guardians, who approve or deny. A committed guardian's
6 * own Follow is auto-accepted (it needs no gate). Quorum policy per ward:
7 * 'any' (one guardian suffices, default), 'all', or 'none' (open).
8 *
9 * This module is the store + the decision; the AP plumbing (sending the
10 * Accept, inserting the follower) stays in ActivityPubService.
11 */
12import db from '../../config/database.js';
13
14let _s = null;
15function stmts() {
16 if (!_s) {
17 _s = {
18 ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_follows
19 (id, ward_slug, follower_uri, follower_inbox, follower_shared_inbox, follower_name, follower_handle, follower_icon, activity_json, quorum, created_at)
20 VALUES (?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
21 get: db.prepare('SELECT * FROM ap_pending_follows WHERE id = ?'),
22 byWard: db.prepare("SELECT * FROM ap_pending_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
23 approvers: db.prepare('SELECT guardian_uri FROM ap_pending_follow_approvals WHERE follow_id = ?'),
24 approve: db.prepare('INSERT OR IGNORE INTO ap_pending_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
25 setStatus: db.prepare('UPDATE ap_pending_follows SET status = ? WHERE id = ?'),
26 del: db.prepare('DELETE FROM ap_pending_follows WHERE id = ?'),
27 };
28 }
29 return _s;
30}
31
32/** Record a gated follow awaiting guardian approval. */
33export function recordPending(wardSlug, f) {
34 stmts().ins.run(
35 f.id, wardSlug, f.follower, f.inbox, f.sharedInbox || null,
36 f.name || null, f.handle || null, f.icon || null,
37 JSON.stringify(f.activity || null), f.quorum || 'any',
38 );
39 return stmts().get.get(f.id);
40}
41
42export function getPending(id) { return stmts().get.get(id); }
43
44/** Pending follows for a local ward (its guardians decide). */
45export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
46
47/**
48 * Record a guardian's decision on a pending follow. Returns
49 * { outcome: 'approved'|'rejected'|'waiting', follow } so the caller can
50 * send the Accept/Reject. A single reject denies; approvals meet the quorum.
51 */
52export function decide(id, guardianUri, decision, guardiansOfWard) {
53 const follow = stmts().get.get(id);
54 if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
55 stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
56 const rows = db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(id);
57 if (rows.some((r) => r.decision === 'reject')) {
58 stmts().setStatus.run('denied', id);
59 return { outcome: 'rejected', follow };
60 }
61 const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
62 const guardians = (guardiansOfWard || []).filter(Boolean);
63 const enough = follow.quorum === 'all'
64 ? guardians.length > 0 && guardians.every((g) => approvers.has(g))
65 : approvers.size >= 1; // 'any' (default)
66 if (enough) {
67 stmts().setStatus.run('accepted', id);
68 return { outcome: 'approved', follow };
69 }
70 return { outcome: 'waiting', follow };
71}
72
73export function remove(id) { stmts().del.run(id); }
74
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. ──
78let _r = null;
79function 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
93export 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}
97export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
98export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
99export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
100
101export default {
102 recordPending, getPending, listForWard, decide, remove,
103 recordReview, getReview, listReviews, removeReview,
104};
Note: See TracBrowser for help on using the repository browser.