source: Klonkt/src/services/guardianship/follows.js@ 1e172f3

main
Last change on this file since 1e172f3 was 1e172f3, checked in by roboburr <roboburr@…>, 5 weeks ago

Modules laden vanuit de shell in plaats van inline script (shaer-bqr, stap 1)

Het mechanisme uit optie C, met de bottom-tab als eerste geval zodat het ook
te bewijzen is.

WAAROM. De CSP-nonce rouleert per verzoek (shaer-0i6). Een script dat via htmx
binnenkomt draagt dus een nonce die het document niet kent en wordt geweigerd.
De chrome komt bij ELKE navigatie out-of-band opnieuw binnen, dus daar valt de
JS bij de eerste klik binnen de site al weg.

HOE. Een bootstrap in shell.ejs -- die komt alleen bij een volledige laadbeurt
binnen en heeft dus wel de goede nonce. Hij leest body[data-js], een lijst
modulenamen, en importeert ze uit /assets/js/mod/. Een dynamische import vanuit
een vertrouwd script is precies waar strict-dynamic voor bedoeld is, dus de
module zelf heeft geen nonce nodig.

Bij een htmx-navigatie zet de pcmsNav-trigger data-js opnieuw en haalt de
bootstrap op wat er nieuw bij staat. 'chrome' staat er altijd bij.

De naam wordt een PAD, dus hij moet door /[a-z0-9-]+$/ -- geen punt, geen
schuine streep.

EERSTE GEVAL: de zoekknop van de bottom-tab. Geen servergegevens erin, al
gedelegeerd, al voorzien van een slot -- dus de verhuizing verandert niets aan de
logica en het mechanisme is er echt mee te toetsen.

WAT DIT BLOOTLEGT VOOR DE VOLGENDE STAP: het topnav-script interpoleert
vertalingen (<%= t('search.section_posts') %>) en kan dus niet zomaar een
statisch bestand worden. Servergegevens horen via een data-attribuut naar een
module, niet via interpolatie in de code. Dat is een eigen stap en staat als
zodanig in mod/chrome.js opgeschreven.

Templates compileren, suite 551/551. Het echte bewijs is een klik BINNEN de site:
na een herlading werkt alles toch al.

  • Property mode set to 100644
File size: 7.2 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,
84 direction, target_uri, target_handle, created_at)
85 VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
86 get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
87 bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
88 del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
89 };
90 }
91 return _r;
92}
93
94/**
95 * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
96 *
97 * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
98 * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
99 * follower en staat het doel in het Follow-object -- die werd hiervoor
100 * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
101 */
102export function recordReview(guardianSlug, r) {
103 const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
104 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
105 r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
106 return rstmts().get.get(guardianSlug, r.id);
107}
108
109/**
110 * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
111 * lezen (offers en outgoing-follows doen het net zo).
112 */
113export function reviewQueueItem(r, me, guardianCount) {
114 // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
115 // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
116 // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
117 const stemmen = (() => {
118 try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
119 catch { return []; }
120 })();
121 const uitgaand = r.direction === 'outgoing';
122 return {
123 id: r.id,
124 type: 'Follow',
125 // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
126 actor: uitgaand ? r.ward_uri : r.follower_uri,
127 object: uitgaand ? (r.target_uri || '') : r.ward_uri,
128 'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
129 'shaer:ward': r.ward_uri,
130 'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
131 'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
132 'shaer:follower': uitgaand ? undefined : r.follower_uri,
133 'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
134 'shaer:quorum': 'all',
135 'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
136 'shaer:guardianCount': guardianCount || undefined,
137 'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
138 published: r.created_at,
139 };
140}
141
142/** De openstaande reviews van een guardian, per richting. */
143export function listReviewsByDirection(guardianSlug, direction) {
144 return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
145}
146export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
147export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
148export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
149
150export default {
151 recordPending, getPending, listForWard, decide, remove,
152 recordReview, getReview, listReviews, removeReview,
153 listReviewsByDirection, reviewQueueItem,
154};
Note: See TracBrowser for help on using the repository browser.