source: Klonkt/src/services/guardianship/gated.js@ 23da947

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

Alle poorten zichtbaar, en de lijst ingeklapt (shaer-ahy.1, vervolg)

Barts wens: de negen geplande gates ook tonen, en de lijst compacter -- het liefst
ingeklapt met een uitklap.

DE NEGEN STAAN ERIN, MAAR NIET ALS DICHTE DEUR. Acht ervan bestaan nog niet:
featureColumn() kent de namen niet, dus een voorstel zou stranden op
unknown_feature. En ze als "uit" tonen zou ronduit onwaar zijn -- plaatjes werken
vandaag gewoon. Ze krijgen daarom een eigen stand, "nog niet beschikbaar", met een
gestippelde chip, geen drempel en geen knop. Een lege plek, geen gesloten poort.

Elke geplande rij draagt zijn bead, zodat het paneel meteen de weg wijst naar waar
die gate gebouwd wordt.

Het kind van de geplande gates is voorlopig en dat staat er ook bij: of
accountmigratie een stand is of een besluit per keer hoort bij het bouwen van
shaer-tge beslist te worden, niet hier. Independence is de enige die gezag
OVERDRAAGT en dus als enige onomkeerbaar (shaer-90v).

INGEKLAPT MET EEN SAMENVATTING. Met twaalf poorten duwt een open lijst alles wat
eronder staat -- guardians, volgverzoeken, hulpvragen -- van het scherm. De regel
erboven zegt wat je meestal wilt weten: hoeveel poorten er zijn, hoeveel er aan
staan en hoeveel er op je wachten. Uitklappen blijft over een verversing heen
staan, net als het wardpaneel zelf; anders valt hij elke 45 seconden dicht.

Negen labels in nl/en/de. Suite 611/611.

  • Property mode set to 100644
File size: 19.3 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
3 *
4 * The point of this file is that it works when the guardians are NOT on the
5 * ward's server, which is the ordinary case: a child on the family instance, a
6 * grandparent on theirs. A guardian proposes with an `Offer` of a
7 * `shaer:GatedSetting` addressed to the ward's server; the other guardians
8 * answer; the ward's server tallies and enforces, because it is the one that
9 * serves the feed.
10 *
11 * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
12 * majority), a window. A setting is reversible (a permission granted can be
13 * withdrawn), so it settles as a race to the threshold and fails closed.
14 */
15import db from '../../config/database.js';
16import { listGuardians } from './relations.js';
17import * as availability from './availability.js';
18
19/** The window a gated-setting decision stays open. Reversible, so a day. */
20export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
21
22/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
23export function thresholdFor(setSize) {
24 return Math.floor(setSize / 2) + 1;
25}
26
27/**
28 * Tally one decision. Pure, so the rule can be tested without a database.
29 *
30 * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
31 * @param {string[]} guardianSet the guardians at the moment the decision opened
32 * @param {number} ageMs how long the decision has been open
33 * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
34 */
35export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
36 const set = new Set((guardianSet || []).filter(Boolean));
37 if (!set.size) return { state: 'expired' }; // nobody may decide
38 const need = thresholdFor(set.size);
39 // Only answers from the snapshotted set count, one per guardian.
40 const seen = new Map();
41 for (const v of (votes || [])) {
42 if (!set.has(v.guardian_uri)) continue;
43 seen.set(v.guardian_uri, v.value === true || v.value === 1);
44 }
45 const yes = [...seen.values()].filter(Boolean).length;
46 const no = seen.size - yes;
47 // Race to the threshold, in both directions: settle the moment it is reached,
48 // and give up the moment it can no longer be reached.
49 if (yes >= need) return { state: 'settled', value: true };
50 if (no >= need) return { state: 'settled', value: false };
51 const undecided = set.size - seen.size;
52 if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
53 if (ageMs >= windowMs) return { state: 'expired' }; // fails closed
54 return { state: 'open' };
55}
56
57/** The column a feature maps onto. Unknown features are refused, not guessed. */
58const FEATURES = {
59 'shaer:externalEmbeds': 'external_embeds',
60 'shaer:externalPlayback': 'external_playback',
61};
62/**
63 * De gates die deze Klonkt kent, met hun SOORT.
64 *
65 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
66 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
67 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
68 * te zijn en geen nieuw stuk scherm.
69 *
70 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
71 * een guardian niet hoeven raden:
72 *
73 * setting een stand, aan of uit, terug te draaien
74 * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
75 * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
76 *
77 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
78 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
79 */
80export const GATE_CATALOGUE = [
81 // Werkend: er is een kolom, de tally kan erover beslissen en de server dwingt
82 // hem af bij het serveren.
83 { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
84 { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
85 // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
86 // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
87 { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
88
89 // GEPLAND, nog niet afgedwongen. Deze staan in het paneel omdat een guardian
90 // hoort te zien wat er straks te beslissen valt -- en omdat de SOM van de
91 // gates iets anders is dan elke gate apart: elf poorten die elk dicht falen
92 // leveren samen een kind op dat vrijwel niets kan.
93 //
94 // available: false is geen detail. featureColumn() kent deze namen niet, dus
95 // een voorstel zou stranden op unknown_feature. En ze als "uit" tonen zou
96 // ronduit onwaar zijn: plaatjes werken vandaag gewoon. Ze horen te lezen als
97 // "hier is nog niets van", niet als een gesloten poort.
98 //
99 // `kind` is hier voorlopig. Of accountmigratie een stand is of een besluit per
100 // keer hoort bij het bouwen van shaer-tge beslist te worden, niet hier.
101 { feature: 'shaer:images', kind: 'setting', reversible: true, available: false, bead: 'shaer-6p5' },
102 { feature: 'shaer:messages', kind: 'setting', reversible: true, available: false, bead: 'shaer-3ow' },
103 { feature: 'shaer:compose', kind: 'setting', reversible: true, available: false, bead: 'shaer-qgev' },
104 { feature: 'shaer:music', kind: 'setting', reversible: true, available: false, bead: 'shaer-rmz' },
105 { feature: 'shaer:quoteCards', kind: 'setting', reversible: true, available: false, bead: 'shaer-mls' },
106 { feature: 'shaer:customEmoji', kind: 'setting', reversible: true, available: false, bead: 'shaer-ytw' },
107 { feature: 'shaer:publicProfile', kind: 'setting', reversible: true, available: false, bead: 'shaer-hj0' },
108 { feature: 'shaer:accountMove', kind: 'setting', reversible: true, available: false, bead: 'shaer-tge' },
109 // De enige die gezag OVERDRAAGT, en daarmee de enige die niet terug te draaien
110 // is zodra het kind hem gebruikt (shaer-90v). Telt met de lapse-vorm: volle
111 // set, volle venster.
112 { feature: 'shaer:independence', kind: 'handover', reversible: false, available: false, bead: 'shaer-90v' },
113];
114
115/**
116 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
117 * getoetst kunnen worden zonder database of scherm.
118 *
119 * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit
120 * @param guardianCount aantal guardians, of null als we het niet weten
121 * @param proposals [{feature, value, status}] lopende voorstellen
122 * @param waiting {feature: aantal} wat er per gate op een besluit wacht
123 */
124export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
125 return GATE_CATALOGUE.map((g) => {
126 // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
127 // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
128 // suggereren dat niemand nam.
129 const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
130 const beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
131 const value = raw && typeof raw === 'object' ? raw.value : raw;
132 // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
133 // bij een ward elders kennen we de stand niet, en verbergen betekende daar
134 // ooit dat een voorstel nooit geopend kon worden.
135 const bovenliggend = settings[g.needs];
136 const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
137 const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
138 // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
139 const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
140 return {
141 feature: g.feature,
142 kind: g.kind,
143 reversible: !!g.reversible,
144 value,
145 decided: beslist,
146 // Vast staat vast: tonen mag, verzetten niet.
147 // Wat er niet is, valt niet te verzetten. Een knop die op unknown_feature
148 // strandt is erger dan geen knop.
149 available: g.available !== false,
150 adjustable: g.available !== false && !g.fixed && !blockedBy,
151 blockedBy: blockedBy || undefined,
152 // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
153 // leest als een feit, en dit is precies waar een guardian op afgaat.
154 threshold: (guardianCount && guardianCount > 0)
155 ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
156 proposal: proposals.find((p) => p.feature === g.feature) || undefined,
157 waiting: waiting[g.feature] || undefined,
158 };
159 });
160}
161
162export function featureColumn(feature) {
163 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
164}
165
166/**
167 * Record one guardian's answer and settle if the threshold is now reached.
168 * Returns the tally state so a caller can report it.
169 */
170export function recordGatedVote(slug, feature, guardianUri, value) {
171 const column = featureColumn(feature);
172 if (!column) return { state: 'expired', error: 'unknown_feature' };
173 const all = listGuardians(slug).map((g) => g.other_uri);
174 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
175 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
176 // restored first, so it always counts itself back into the set below.
177 availability.oneAnswer(guardianUri, Date.now());
178 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
179 // against the full list above: any guardian may answer, and answering is
180 // exactly what brings it back in.
181 const guardians = availability.availableSet(slug, all, Date.now());
182
183 // The window opens with the first answer, and a stale decision starts over:
184 // a proposal from last month should not silently count toward today's.
185 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
186 .get(slug, feature);
187 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
188 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
189 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
190 openedAt = Date.now();
191 }
192 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
193 VALUES (?,?,?,?,?)
194 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
195 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
196
197 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
198 .all(slug, feature);
199 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
200 if (result.state === 'settled') {
201 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
202 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
203 } else if (result.state === 'expired') {
204 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
205 }
206 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
207}
208
209/** The open decision for a feature, for showing progress ("1 of 2"). */
210export function gatedProgress(slug, feature) {
211 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
212 .all(slug, feature);
213 // Progress over the available set (§3.5), like the tally itself.
214 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
215 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
216}
217
218// ── The federated shape (§5.6) ────────────────────────────────────
219// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
220// here so both the inbox and the outbox read it the same way.
221
222/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
223export function parseGatedSetting(object) {
224 if (!object || typeof object !== 'object') return null;
225 const type = Array.isArray(object.type) ? object.type[0] : object.type;
226 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
227 const ward = object['shaer:ward'] || object.ward;
228 const feature = object['shaer:feature'] || object.feature;
229 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
230 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
231 return { ward, feature, value: value === true || value === 1 || value === 'true' };
232}
233
234/** Build the Offer a guardian sends to the ward's server. */
235export function buildGatedOffer(offerId, actor, ward, feature, value) {
236 return {
237 id: offerId,
238 type: 'Offer',
239 actor,
240 to: [ward],
241 object: {
242 type: 'shaer:GatedSetting',
243 'shaer:ward': ward,
244 'shaer:feature': feature,
245 'shaer:value': !!value,
246 },
247 };
248}
249
250// ── The guardian-side copy (the missing leg of §5.6) ──────────────
251// A proposal addressed to the ward's server reaches only the proposer and the
252// ward. The other guardians never learn it exists, so a threshold of two can
253// never be met and every proposal expires unanswered. The ward's server
254// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
255// guardian stores a copy it can answer, and the answer travels back to the
256// ward, which tallies.
257
258let _rs = null;
259function rstmts() {
260 if (!_rs) {
261 _rs = {
262 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
263 VALUES (?,?,?,?,?,?,?)
264 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
265 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
266 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
267 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
268 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
269 };
270 }
271 return _rs;
272}
273
274export function recordGatedReview(guardianSlug, r) {
275 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
276 return rstmts().get.get(guardianSlug, r.id);
277}
278export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
279export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
280export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
281/** Drop every guardian's copy once the decision has settled or lapsed. */
282export function clearGatedReviews(id) { rstmts().delAll.run(id); }
283
284export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
285 try {
286 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
287 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
288 } catch { /* non-fatal */ }
289}
290
291export function recallGatedOffer(offerId) {
292 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
293 catch { return null; }
294}
295
296// ── The proposer's own record (5.6) ───────────────────────────────
297// "Where did my proposal go?" had no answer: the status was a button caption
298// that did not survive a refresh. The ward's server tallies elsewhere, so the
299// proposer keeps its own row and the ward's server ANSWERS the Offer when the
300// decision settles: Accept when it settled on the proposed value, Reject when
301// it settled on the opposite. An open row past the window renders as expired,
302// because an expired decision settles on nothing and nobody writes home.
303
304export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
305 try {
306 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
307 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
308 } catch { /* non-fatal */ }
309}
310
311export function recallSent(offerId) {
312 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
313 catch { return null; }
314}
315
316/**
317 * De stand van een gate zoals DEZE guardian hem kent.
318 *
319 * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
320 * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
321 * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
322 * true betekent dat de poort openging.
323 *
324 * Geeft { value, decided }:
325 * decided true we hebben een aangenomen besluit gezien; value is die waarde
326 * decided false we hebben er geen; value is de standaard voor een ward (uit)
327 *
328 * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
329 * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
330 *
331 * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
332 * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
333 * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
334 * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
335 * onveilige kant en het hoort gerepareerd te worden.
336 */
337export function knownSetting(guardianSlug, wardUri, feature) {
338 try {
339 const r = db.prepare(`SELECT value FROM ap_gated_sent
340 WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
341 ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
342 if (r) return { value: !!r.value, decided: true };
343 } catch { /* val terug op de standaard */ }
344 return { value: false, decided: false };
345}
346
347export function settleSent(offerId, outcome) {
348 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
349}
350
351/** The latest proposal per feature this guardian sent to this ward. */
352export function listSent(guardianSlug, wardUri) {
353 try {
354 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
355 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
356 } catch { return []; }
357}
358
359/**
360 * What a sent row means on a screen. Pure, so the rule is testable: an answer
361 * wins, and silence past the window is not "still running", it is over.
362 */
363export function sentStatus(row, now) {
364 if (!row) return null;
365 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
366 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
367 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
368 return 'open';
369}
370
371export default {
372 GATE_CATALOGUE, gateRows, knownSetting,
373 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
374 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
375 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
376 recordSent, recallSent, settleSent, listSent, sentStatus,
377};
Note: See TracBrowser for help on using the repository browser.