source: Klonkt/src/services/guardianship/outgoing.js@ 5e16b8a

main
Last change on this file since 5e16b8a was d3aa63b, checked in by Bart <bart@…>, 4 weeks ago

De ward is een URI, geen slug: eigen volgverzoeken kwamen bij zichzelf niet aan

queueItem zette de slug in actor ('mee'), terwijl elke lezer dat veld met
actor-URI's vergelijkt -- de Swift-parser zegt er zelfs bij dat het een URI
hoort te zijn. Een ward herkende zijn eigen verzoeken dus nooit als de zijne.
Ze vielen in de bak "hoort niet bij een ward die je hebt", onder een kop die
zegt dat er iets uit de pas loopt, met Approve/Reject eronder die de server met
403 not_a_guardian beantwoordt.

listForWard(slug) levert per definitie de verzoeken van de LEZER, dus me is
het antwoord. En het klopt ook als AP: de ward is de actor van zijn eigen
Follow.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 6.0 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.3, the other direction) — gating a ward's OWN
3 * follows. Bead shaer-p729; the design is in docs/ward-outbound-follows-design.md,
4 * and the spec question it answers is shaer-yeo5.
5 *
6 * The inbound gate in `follows.js` decides who may follow a ward. This one
7 * decides who a ward may follow. Until now that went out unchecked: the
8 * guardians got a note afterwards (1a2f206), which is informing, not gating —
9 * the door is already open by the time the message arrives.
10 *
11 * The rule (Barts besluit): every outgoing follow waits for a guardian, EXCEPT
12 * where the target already follows the ward through the gate. A guardian
13 * already said yes to that person; asking the same question twice only teaches
14 * people to stop reading the question.
15 */
16import db from '../../config/database.js';
17import { followThreshold } from './follows.js';
18
19let _s = null;
20function stmts() {
21 if (!_s) {
22 _s = {
23 ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_outgoing_follows
24 (id, ward_slug, target_uri, target_inbox, target_name, target_handle, target_icon, quorum, created_at)
25 VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
26 get: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE id = ?'),
27 byTarget: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
28 byWard: db.prepare("SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
29 approve: db.prepare('INSERT OR IGNORE INTO ap_outgoing_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
30 answers: db.prepare('SELECT guardian_uri, decision FROM ap_outgoing_follow_approvals WHERE follow_id = ?'),
31 setStatus: db.prepare('UPDATE ap_pending_outgoing_follows SET status = ? WHERE id = ?'),
32 del: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE id = ?'),
33 delByTarget: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
34 gateApproved: db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? AND gate_approved = 1'),
35 };
36 }
37 return _s;
38}
39
40/**
41 * Does this target already follow the ward, with a guardian's blessing?
42 *
43 * Only a gate-approved follower counts. A follower a free actor picked up
44 * before it was ever a ward was never seen by a guardian, so following them
45 * back is a new question, not a settled one. (Rows that predate the marker are
46 * grandfathered at migration; see config/database.js.)
47 */
48export function isMutual(wardSlug, targetUri) {
49 return !!stmts().gateApproved.get(wardSlug, targetUri);
50}
51
52/** Record an outgoing follow awaiting guardian approval. */
53export function recordPending(wardSlug, f) {
54 stmts().ins.run(
55 f.id, wardSlug, f.target, f.inbox || null,
56 f.name || null, f.handle || null, f.icon || null, f.quorum || 'any',
57 );
58 return stmts().byTarget.get(wardSlug, f.target);
59}
60
61export function getPending(id) { return stmts().get.get(id); }
62export function findFor(wardSlug, targetUri) { return stmts().byTarget.get(wardSlug, targetUri); }
63
64/** Outgoing follows this ward is waiting on — the guardian's queue. */
65export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
66
67/**
68 * A guardian's answer. Same shape and the same quorum arithmetic as the
69 * inbound gate, so the two directions cannot drift apart in how they count:
70 * a single reject denies outright, approvals accumulate toward the quorum.
71 */
72export function decide(id, guardianUri, decision, guardiansOfWard) {
73 const follow = stmts().get.get(id);
74 if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
75 stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
76 const rows = stmts().answers.all(id);
77 if (rows.some((r) => r.decision === 'reject')) {
78 stmts().setStatus.run('denied', id);
79 return { outcome: 'rejected', follow };
80 }
81 const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
82 const guardians = (guardiansOfWard || []).filter(Boolean);
83 // Dezelfde eenvoudige meerderheid als bij een inkomend volgverzoek
84 // (followThreshold): het is dezelfde vraag, alleen omgedraaid. Twee
85 // verschillende drempels voor "mag dit kind met deze persoon te maken hebben"
86 // zou een guardian nooit kunnen uitleggen.
87 const enough = approvers.size >= followThreshold(guardians.length);
88 if (enough) {
89 stmts().setStatus.run('approved', id);
90 return { outcome: 'approved', follow };
91 }
92 return { outcome: 'waiting', follow };
93}
94
95/** The ward changed its mind, or blocked the target: the request is gone. */
96export function withdraw(wardSlug, targetUri) { stmts().delByTarget.run(wardSlug, targetUri); }
97export function remove(id) { stmts().del.run(id); }
98
99/** One request as the queue item the Shaer clients parse, mirroring the
100 * inbound gated-follow item so a dashboard can render both side by side. */
101export function queueItem(o, me) {
102 const rows = stmts().answers.all(o.id);
103 return {
104 id: o.id,
105 type: 'Follow',
106 // De ward is de ACTOR van zijn eigen Follow, en dat hoort een actor-URI te
107 // zijn: hier stond de slug ('mee'), en elke lezer vergelijkt dit veld met
108 // actor-URI's. Een ward zag zijn eigen verzoeken daardoor nooit als de
109 // zijne -- ze vielen in de bak "hoort niet bij een ward die je hebt", met
110 // knoppen erbij die hij niet mag gebruiken. `listForWard(slug)` levert per
111 // definitie de verzoeken van de LEZER, dus dat is `me`.
112 actor: me,
113 object: o.target_uri,
114 'shaer:direction': 'outgoing',
115 'shaer:target': o.target_uri,
116 'shaer:targetHandle': o.target_handle || undefined,
117 'shaer:quorum': o.quorum || 'any',
118 'shaer:approvals': rows.filter((r) => r.decision === 'approve').length,
119 'shaer:myVote': rows.some((r) => r.guardian_uri === me),
120 published: o.created_at,
121 };
122}
123
124export default {
125 isMutual, recordPending, getPending, findFor, listForWard, decide, withdraw, remove, queueItem,
126};
Note: See TracBrowser for help on using the repository browser.