source: Klonkt/src/services/guardianship/follows.js@ 8f520e0

main
Last change on this file since 8f520e0 was 5c373b8, checked in by Robin <roboburr@…>, 7 weeks ago

Guardian 2: follow-goedkeuring (FEP-633c §5.3)

Een Follow op een ward wordt niet meer automatisch geaccepteerd: hij wacht op
goedkeuring van de guardians. Afgebakend zoals de spec: gating geldt ALLEEN voor
ward-actors (die guardians hebben); een gewone site zonder guardians accepteert
als vanouds, geen gedragswijziging. Een gecommitte guardian die zelf volgt wordt
wel meteen geaccepteerd (Barts regel: die heeft geen gate nodig, en zo werkt het
meekijken). Quorum per ward: 'any' (default, één volstaat), 'all' of 'none'; een
enkele reject weigert.

De guardian ziet de verzoeken in /guardian2 (ward en guardian zitten op dezelfde
familie-Klonkt, dus lokaal leesbaar) en tikt Accept/Deny. Bij goedkeuring stuurt
Klonkt de Accept(Follow) en legt de follower vast, zodat bezorging (ook
followers-only) begint. Cross-instance federatie van de goedkeuring is een latere
verfijning (de daemon heeft het patroon).

Changed files:
src/services/ActivityPubService.js

  • inbound Follow: gate voor ward-actors; acceptGatedFollow/rejectGatedFollow

src/config/database.js

  • ap_pending_follows + ap_pending_follow_approvals

src/services/guardianship/index.js

  • follows-module geexporteerd

src/routes/guardian2.js

  • GET /api/follow-requests, POST /api/follow/:id (guardian-gecheckt)

src/views/pages/guardian2.ejs, src/assets/js/guardian2.js

src/services/i18n.js

  • guardian2 follow_title/follow_sub (nl/en/de)

New file:
src/services/guardianship/follows.js

  • de gating-store + quorum-beslissing (any/all), pure en testbaar

test/follow-gating.test.js

  • any/all-quorum en single-reject

remarks: npm test 167/167. v1 /guardian en niet-ward-sites onaangeraakt.

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

  • Property mode set to 100644
File size: 3.4 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
75export default { recordPending, getPending, listForWard, decide, remove };
Note: See TracBrowser for help on using the repository browser.