| 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 | */
|
|---|
| 16 | import db from '../../config/database.js';
|
|---|
| 17 | import { followThreshold } from './follows.js';
|
|---|
| 18 |
|
|---|
| 19 | let _s = null;
|
|---|
| 20 | function 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 | */
|
|---|
| 48 | export function isMutual(wardSlug, targetUri) {
|
|---|
| 49 | return !!stmts().gateApproved.get(wardSlug, targetUri);
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | /** Record an outgoing follow awaiting guardian approval. */
|
|---|
| 53 | export 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 |
|
|---|
| 61 | export function getPending(id) { return stmts().get.get(id); }
|
|---|
| 62 | export function findFor(wardSlug, targetUri) { return stmts().byTarget.get(wardSlug, targetUri); }
|
|---|
| 63 |
|
|---|
| 64 | /** Outgoing follows this ward is waiting on — the guardian's queue. */
|
|---|
| 65 | export 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 | */
|
|---|
| 72 | export 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. */
|
|---|
| 96 | export function withdraw(wardSlug, targetUri) { stmts().delByTarget.run(wardSlug, targetUri); }
|
|---|
| 97 | export 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. */
|
|---|
| 101 | export function queueItem(o, me) {
|
|---|
| 102 | const rows = stmts().answers.all(o.id);
|
|---|
| 103 | return {
|
|---|
| 104 | id: o.id,
|
|---|
| 105 | type: 'Follow',
|
|---|
| 106 | actor: o.ward_slug,
|
|---|
| 107 | object: o.target_uri,
|
|---|
| 108 | 'shaer:direction': 'outgoing',
|
|---|
| 109 | 'shaer:target': o.target_uri,
|
|---|
| 110 | 'shaer:targetHandle': o.target_handle || undefined,
|
|---|
| 111 | 'shaer:quorum': o.quorum || 'any',
|
|---|
| 112 | 'shaer:approvals': rows.filter((r) => r.decision === 'approve').length,
|
|---|
| 113 | 'shaer:myVote': rows.some((r) => r.guardian_uri === me),
|
|---|
| 114 | published: o.created_at,
|
|---|
| 115 | };
|
|---|
| 116 | }
|
|---|
| 117 |
|
|---|
| 118 | export default {
|
|---|
| 119 | isMutual, recordPending, getPending, findFor, listForWard, decide, withdraw, remove, queueItem,
|
|---|
| 120 | };
|
|---|