| 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 | */
|
|---|
| 12 | import db from '../../config/database.js';
|
|---|
| 13 |
|
|---|
| 14 | let _s = null;
|
|---|
| 15 | function 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. */
|
|---|
| 33 | export 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 |
|
|---|
| 42 | export function getPending(id) { return stmts().get.get(id); }
|
|---|
| 43 |
|
|---|
| 44 | /** Pending follows for a local ward (its guardians decide). */
|
|---|
| 45 | export 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 | */
|
|---|
| 52 | /**
|
|---|
| 53 | * Hoeveel guardians moeten ja zeggen voor een volgverzoek (Barts besluit, 8-8).
|
|---|
| 54 | *
|
|---|
| 55 | * EENVOUDIGE MEERDERHEID: 1 van 1, 1 van 2, 2 van 3, 2 van 4. Bart: "1/2 is
|
|---|
| 56 | * voldoende."
|
|---|
| 57 | *
|
|---|
| 58 | * BEWUST SOEPELER DAN DE POORTDREMPEL, en dat verschil hoort uitgelegd. Een gate
|
|---|
| 59 | * opent een deur voor alles wat daarna komt; die vraagt om een STRIKTE
|
|---|
| 60 | * meerderheid (thresholdFor in gated.js: 2 van 2, 3 van 4). Een volgverzoek gaat
|
|---|
| 61 | * over een persoon, is met ontvolgen terug te draaien, en stond hier tot vandaag
|
|---|
| 62 | * op 'any' -- een enkele ja, hoeveel guardians er ook waren. Dit is dus geen
|
|---|
| 63 | * versoepeling maar een AANSCHERPING voor iedereen met drie of meer guardians.
|
|---|
| 64 | *
|
|---|
| 65 | * De 'all'-stand die hier stond is weg. Hij werd nergens gezet -- elke schrijver
|
|---|
| 66 | * gaf 'any' mee -- dus het was een keuze die niemand kon maken en die alleen in
|
|---|
| 67 | * de weg stond bij het lezen van deze regel.
|
|---|
| 68 | */
|
|---|
| 69 | export function followThreshold(setSize) {
|
|---|
| 70 | return Math.max(1, Math.ceil(setSize / 2));
|
|---|
| 71 | }
|
|---|
| 72 |
|
|---|
| 73 | /**
|
|---|
| 74 | * Een race naar de drempel, net als de poorttelling: zodra het aantal gehaald
|
|---|
| 75 | * is, is het besluit gevallen.
|
|---|
| 76 | *
|
|---|
| 77 | * TODO (shaer-8vt): wie antwoordt weet niet dat hij de doorslag geeft. Bij 1 van
|
|---|
| 78 | * 2 is de eerste ja meteen de beslissing, en het scherm zegt dat nergens. Dat is
|
|---|
| 79 | * hetzelfde gat als bij de gate-voorstellen en het hoort daar samen opgelost.
|
|---|
| 80 | */
|
|---|
| 81 | export function decide(id, guardianUri, decision, guardiansOfWard) {
|
|---|
| 82 | const follow = stmts().get.get(id);
|
|---|
| 83 | if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
|
|---|
| 84 | stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
|
|---|
| 85 | const rows = db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(id);
|
|---|
| 86 | if (rows.some((r) => r.decision === 'reject')) {
|
|---|
| 87 | stmts().setStatus.run('denied', id);
|
|---|
| 88 | return { outcome: 'rejected', follow };
|
|---|
| 89 | }
|
|---|
| 90 | const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
|
|---|
| 91 | const guardians = (guardiansOfWard || []).filter(Boolean);
|
|---|
| 92 | const enough = approvers.size >= followThreshold(guardians.length);
|
|---|
| 93 | if (enough) {
|
|---|
| 94 | stmts().setStatus.run('accepted', id);
|
|---|
| 95 | return { outcome: 'approved', follow };
|
|---|
| 96 | }
|
|---|
| 97 | return { outcome: 'waiting', follow };
|
|---|
| 98 | }
|
|---|
| 99 |
|
|---|
| 100 | export function remove(id) { stmts().del.run(id); }
|
|---|
| 101 |
|
|---|
| 102 | // ── Guardian-side copy (cross-instance, modelled on the guardian offer): a
|
|---|
| 103 | // gated follow on a REMOTE ward this account guards, forwarded here as an
|
|---|
| 104 | // Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ──
|
|---|
| 105 | let _r = null;
|
|---|
| 106 | function rstmts() {
|
|---|
| 107 | if (!_r) {
|
|---|
| 108 | _r = {
|
|---|
| 109 | ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
|
|---|
| 110 | (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
|
|---|
| 111 | direction, target_uri, target_handle, created_at)
|
|---|
| 112 | VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
|
|---|
| 113 | get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 114 | bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
|
|---|
| 115 | del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 116 | };
|
|---|
| 117 | }
|
|---|
| 118 | return _r;
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | /**
|
|---|
| 122 | * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
|
|---|
| 123 | *
|
|---|
| 124 | * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
|
|---|
| 125 | * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
|
|---|
| 126 | * follower en staat het doel in het Follow-object -- die werd hiervoor
|
|---|
| 127 | * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
|
|---|
| 128 | */
|
|---|
| 129 | export function recordReview(guardianSlug, r) {
|
|---|
| 130 | const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
|
|---|
| 131 | rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
|
|---|
| 132 | r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
|
|---|
| 133 | return rstmts().get.get(guardianSlug, r.id);
|
|---|
| 134 | }
|
|---|
| 135 |
|
|---|
| 136 | /**
|
|---|
| 137 | * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
|
|---|
| 138 | * lezen (offers en outgoing-follows doen het net zo).
|
|---|
| 139 | */
|
|---|
| 140 | export function reviewQueueItem(r, me, guardianCount) {
|
|---|
| 141 | // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
|
|---|
| 142 | // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
|
|---|
| 143 | // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
|
|---|
| 144 | const stemmen = (() => {
|
|---|
| 145 | try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
|
|---|
| 146 | catch { return []; }
|
|---|
| 147 | })();
|
|---|
| 148 | const uitgaand = r.direction === 'outgoing';
|
|---|
| 149 | return {
|
|---|
| 150 | id: r.id,
|
|---|
| 151 | type: 'Follow',
|
|---|
| 152 | // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
|
|---|
| 153 | actor: uitgaand ? r.ward_uri : r.follower_uri,
|
|---|
| 154 | object: uitgaand ? (r.target_uri || '') : r.ward_uri,
|
|---|
| 155 | 'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
|
|---|
| 156 | 'shaer:ward': r.ward_uri,
|
|---|
| 157 | 'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
|
|---|
| 158 | 'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
|
|---|
| 159 | 'shaer:follower': uitgaand ? undefined : r.follower_uri,
|
|---|
| 160 | 'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
|
|---|
| 161 | 'shaer:quorum': 'all',
|
|---|
| 162 | 'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
|
|---|
| 163 | 'shaer:guardianCount': guardianCount || undefined,
|
|---|
| 164 | 'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
|
|---|
| 165 | published: r.created_at,
|
|---|
| 166 | };
|
|---|
| 167 | }
|
|---|
| 168 |
|
|---|
| 169 | /** De openstaande reviews van een guardian, per richting. */
|
|---|
| 170 | export function listReviewsByDirection(guardianSlug, direction) {
|
|---|
| 171 | return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
|
|---|
| 172 | }
|
|---|
| 173 | export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
|
|---|
| 174 | export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
|
|---|
| 175 | export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
|
|---|
| 176 |
|
|---|
| 177 | export default {
|
|---|
| 178 | recordPending, getPending, listForWard, decide, remove,
|
|---|
| 179 | recordReview, getReview, listReviews, removeReview,
|
|---|
| 180 | listReviewsByDirection, reviewQueueItem,
|
|---|
| 181 | };
|
|---|