| 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 | */
|
|---|
| 15 | import db from '../../config/database.js';
|
|---|
| 16 | import { listGuardians } from './relations.js';
|
|---|
| 17 | import * as availability from './availability.js';
|
|---|
| 18 |
|
|---|
| 19 | /** The window a gated-setting decision stays open. Reversible, so a day. */
|
|---|
| 20 | export 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. */
|
|---|
| 23 | export 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 | */
|
|---|
| 35 | export 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. */
|
|---|
| 58 | const FEATURES = {
|
|---|
| 59 | 'shaer:externalEmbeds': 'external_embeds',
|
|---|
| 60 | 'shaer:externalPlayback': 'external_playback',
|
|---|
| 61 | 'shaer:externalThreads': 'external_threads',
|
|---|
| 62 | 'shaer:images': 'gate_images',
|
|---|
| 63 | 'shaer:messages': 'gate_messages',
|
|---|
| 64 | 'shaer:compose': 'gate_compose',
|
|---|
| 65 | 'shaer:replies': 'gate_replies',
|
|---|
| 66 | 'shaer:music': 'gate_music',
|
|---|
| 67 | 'shaer:quoteCards': 'gate_quote_cards',
|
|---|
| 68 | 'shaer:customEmoji': 'gate_custom_emoji',
|
|---|
| 69 | 'shaer:accountMove': 'gate_account_move',
|
|---|
| 70 | 'shaer:following': 'gate_following',
|
|---|
| 71 | };
|
|---|
| 72 | /**
|
|---|
| 73 | * De gates die deze Klonkt kent, met hun SOORT.
|
|---|
| 74 | *
|
|---|
| 75 | * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
|
|---|
| 76 | * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
|
|---|
| 77 | * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
|
|---|
| 78 | * te zijn en geen nieuw stuk scherm.
|
|---|
| 79 | *
|
|---|
| 80 | * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
|
|---|
| 81 | * een guardian niet hoeven raden:
|
|---|
| 82 | *
|
|---|
| 83 | * setting een stand, aan of uit, terug te draaien
|
|---|
| 84 | * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
|
|---|
| 85 | * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
|
|---|
| 86 | *
|
|---|
| 87 | * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
|
|---|
| 88 | * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
|
|---|
| 89 | */
|
|---|
| 90 | export const GATE_CATALOGUE = [
|
|---|
| 91 | // Werkend: er is een kolom, de tally kan erover beslissen en de server dwingt
|
|---|
| 92 | // hem af bij het serveren (of, voor compose/messages/move, bij het INNEMEN:
|
|---|
| 93 | // wat de ward niet mag versturen wordt aan de outbox geweigerd).
|
|---|
| 94 | { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
|
|---|
| 95 | { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
|
|---|
| 96 | // Sinds 8-8 ("maak ze allemaal functioneel", Bart): de hele setting-familie
|
|---|
| 97 | // schakelt echt. De bead-nummers blijven staan, want elk van deze heeft nog
|
|---|
| 98 | // een app-kant (wat de UI toont als de poort dicht is) en die woont daar.
|
|---|
| 99 | { feature: 'shaer:externalThreads', kind: 'setting', reversible: true, bead: 'shaer-9y2' },
|
|---|
| 100 | { feature: 'shaer:images', kind: 'setting', reversible: true, bead: 'shaer-6p5' },
|
|---|
| 101 | { feature: 'shaer:messages', kind: 'setting', reversible: true, bead: 'shaer-3ow' },
|
|---|
| 102 | { feature: 'shaer:compose', kind: 'setting', reversible: true, bead: 'shaer-qgev' },
|
|---|
| 103 | // MEEDOEN AAN EEN GESPREK IS OOK IETS (Bart, 8-8). Dit stond hier bewust niet:
|
|---|
| 104 | // een antwoord gold als meedoen en niet als eigen podium, dus compose liet het
|
|---|
| 105 | // door. Bart heeft dat teruggedraaid -- wie mag antwoorden staat los van wie
|
|---|
| 106 | // mag posten, en het hoort een eigen poort te zijn die je kunt zien.
|
|---|
| 107 | //
|
|---|
| 108 | // Los van compose en niet eronder: je kunt willen dat een kind wel meepraat
|
|---|
| 109 | // maar geen eigen podium heeft, en ook precies andersom.
|
|---|
| 110 | { feature: 'shaer:replies', kind: 'setting', reversible: true, bead: 'shaer-r4c' },
|
|---|
| 111 | { feature: 'shaer:music', kind: 'setting', reversible: true, bead: 'shaer-rmz' },
|
|---|
| 112 | { feature: 'shaer:quoteCards', kind: 'setting', reversible: true, bead: 'shaer-mls' },
|
|---|
| 113 | { feature: 'shaer:customEmoji', kind: 'setting', reversible: true, bead: 'shaer-ytw' },
|
|---|
| 114 | { feature: 'shaer:accountMove', kind: 'setting', reversible: true, bead: 'shaer-tge' },
|
|---|
| 115 | // Wie de ward mag VOLGEN, en wie de ward mag volgen: twee poorten, want twee
|
|---|
| 116 | // vragen. Ze stonden hier als één rij, en dan telt het paneel de ene richting
|
|---|
| 117 | // en zwijgt over de andere -- een guardian ziet "follows: 3 wachtend" en weet
|
|---|
| 118 | // niet of er drie vreemden bij zijn kind willen of dat zijn kind drie keer
|
|---|
| 119 | // heeft gevraagd of het iemand mag volgen. Dat zijn niet dezelfde zorg.
|
|---|
| 120 | //
|
|---|
| 121 | // Inkomend is vast: §5.3 EIST dat een Follow naar een ward langs de guardians
|
|---|
| 122 | // gaat, dus die staat aan en blijft aanstaan. Tonen mag, verzetten niet.
|
|---|
| 123 | { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
|
|---|
| 124 | // Uitgaand is verstelbaar, en dat verschil is opzet. De FEP zegt over deze
|
|---|
| 125 | // richting niets: §5.3 gaat alleen over een Follow die op een ward AF komt.
|
|---|
| 126 | // Wat je verder gated is expliciet aan de implementatie gelaten, dus dit is
|
|---|
| 127 | // onze keuze en niet die van de spec -- en dan hoort hij ook echt te kunnen
|
|---|
| 128 | // worden losgelaten, want een kind dat ouder wordt hoort niet eeuwig te
|
|---|
| 129 | // blijven vragen wie het mag volgen (shaer-p729, shaer-yeo5).
|
|---|
| 130 | { feature: 'shaer:following', kind: 'perRequest', reversible: true, bead: 'shaer-p729' },
|
|---|
| 131 |
|
|---|
| 132 | // GEPLAND, en dat is bij deze twee geen achterstand maar een besluit.
|
|---|
| 133 | //
|
|---|
| 134 | // publicProfile is niet een veld dat je wegfiltert: het is het hele publieke
|
|---|
| 135 | // web-oppervlak van een site (de Krant, de AP-objecten, de scrape-vraag van
|
|---|
| 136 | // shaer-hj0). Dat dichtzetten zonder dat ontwerp is een half slot, en een
|
|---|
| 137 | // half slot leest als een heel slot -- gevaarlijker dan geen.
|
|---|
| 138 | //
|
|---|
| 139 | // available: false is geen detail. featureColumn() kent deze naam niet, dus
|
|---|
| 140 | // een voorstel strandt op unknown_feature, en de rij leest als "hier is nog
|
|---|
| 141 | // niets van", niet als een gesloten poort.
|
|---|
| 142 | { feature: 'shaer:publicProfile', kind: 'setting', reversible: true, available: false, bead: 'shaer-hj0' },
|
|---|
| 143 | // De enige die gezag OVERDRAAGT, en daarmee de enige die niet terug te draaien
|
|---|
| 144 | // is zodra het kind hem gebruikt (shaer-90v). Telt met de lapse-vorm: volle
|
|---|
| 145 | // set, volle venster. Die vorm hoort daar beslist te worden, niet hier
|
|---|
| 146 | // geimproviseerd: een verkeerd gemaakte onafhankelijkheid is een kind zonder
|
|---|
| 147 | // vangnet.
|
|---|
| 148 | { feature: 'shaer:independence', kind: 'handover', reversible: false, available: false, bead: 'shaer-90v' },
|
|---|
| 149 | ];
|
|---|
| 150 |
|
|---|
| 151 | /**
|
|---|
| 152 | * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
|
|---|
| 153 | * getoetst kunnen worden zonder database of scherm.
|
|---|
| 154 | *
|
|---|
| 155 | * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit
|
|---|
| 156 | * @param guardianCount aantal guardians, of null als we het niet weten
|
|---|
| 157 | * @param proposals [{feature, value, status}] lopende voorstellen
|
|---|
| 158 | * @param waiting {feature: aantal} wat er per gate op een besluit wacht
|
|---|
| 159 | */
|
|---|
| 160 | export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {}, requested = {} } = {}) {
|
|---|
| 161 | return GATE_CATALOGUE.map((g) => {
|
|---|
| 162 | // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
|
|---|
| 163 | // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
|
|---|
| 164 | // suggereren dat niemand nam.
|
|---|
| 165 | const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
|
|---|
| 166 | const beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
|
|---|
| 167 | const value = raw && typeof raw === 'object' ? raw.value : raw;
|
|---|
| 168 | // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
|
|---|
| 169 | // bij een ward elders kennen we de stand niet, en verbergen betekende daar
|
|---|
| 170 | // ooit dat een voorstel nooit geopend kon worden.
|
|---|
| 171 | const bovenliggend = settings[g.needs];
|
|---|
| 172 | const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
|
|---|
| 173 | const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
|
|---|
| 174 | // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
|
|---|
| 175 | const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
|
|---|
| 176 | return {
|
|---|
| 177 | feature: g.feature,
|
|---|
| 178 | kind: g.kind,
|
|---|
| 179 | reversible: !!g.reversible,
|
|---|
| 180 | value,
|
|---|
| 181 | decided: beslist,
|
|---|
| 182 | // Vast staat vast: tonen mag, verzetten niet.
|
|---|
| 183 | // Wat er niet is, valt niet te verzetten. Een knop die op unknown_feature
|
|---|
| 184 | // strandt is erger dan geen knop.
|
|---|
| 185 | available: g.available !== false,
|
|---|
| 186 | adjustable: g.available !== false && !g.fixed && !blockedBy,
|
|---|
| 187 | blockedBy: blockedBy || undefined,
|
|---|
| 188 | // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
|
|---|
| 189 | // leest als een feit, en dit is precies waar een guardian op afgaat.
|
|---|
| 190 | threshold: (guardianCount && guardianCount > 0)
|
|---|
| 191 | ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
|
|---|
| 192 | proposal: proposals.find((p) => p.feature === g.feature) || undefined,
|
|---|
| 193 | waiting: waiting[g.feature] || undefined,
|
|---|
| 194 | // Het kind vroeg hier zelf om (shaer-8ru). Apart van `waiting`: drie
|
|---|
| 195 | // onbekenden die je kind willen volgen is iets anders dan je kind dat
|
|---|
| 196 | // een keer vraagt of muziek aan mag, en een gedeeld getal maakt daar
|
|---|
| 197 | // hetzelfde van.
|
|---|
| 198 | requested: requested[g.feature] || undefined,
|
|---|
| 199 | };
|
|---|
| 200 | });
|
|---|
| 201 | }
|
|---|
| 202 |
|
|---|
| 203 | export function featureColumn(feature) {
|
|---|
| 204 | return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
|
|---|
| 205 | }
|
|---|
| 206 |
|
|---|
| 207 | /**
|
|---|
| 208 | * Record one guardian's answer and settle if the threshold is now reached.
|
|---|
| 209 | * Returns the tally state so a caller can report it.
|
|---|
| 210 | */
|
|---|
| 211 | export function recordGatedVote(slug, feature, guardianUri, value) {
|
|---|
| 212 | const column = featureColumn(feature);
|
|---|
| 213 | if (!column) return { state: 'expired', error: 'unknown_feature' };
|
|---|
| 214 | const all = listGuardians(slug).map((g) => g.other_uri);
|
|---|
| 215 | if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
|
|---|
| 216 | // A vote is an answer, whatever it is a vote on (§3.6): the voter is
|
|---|
| 217 | // restored first, so it always counts itself back into the set below.
|
|---|
| 218 | availability.oneAnswer(guardianUri, Date.now());
|
|---|
| 219 | // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
|
|---|
| 220 | // against the full list above: any guardian may answer, and answering is
|
|---|
| 221 | // exactly what brings it back in.
|
|---|
| 222 | const guardians = availability.availableSet(slug, all, Date.now());
|
|---|
| 223 |
|
|---|
| 224 | // The window opens with the first answer, and a stale decision starts over:
|
|---|
| 225 | // a proposal from last month should not silently count toward today's.
|
|---|
| 226 | const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 227 | .get(slug, feature);
|
|---|
| 228 | let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
|
|---|
| 229 | if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
|
|---|
| 230 | db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
|
|---|
| 231 | openedAt = Date.now();
|
|---|
| 232 | }
|
|---|
| 233 | db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
|
|---|
| 234 | VALUES (?,?,?,?,?)
|
|---|
| 235 | ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
|
|---|
| 236 | .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
|
|---|
| 237 |
|
|---|
| 238 | const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 239 | .all(slug, feature);
|
|---|
| 240 | const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
|
|---|
| 241 | if (result.state === 'settled') {
|
|---|
| 242 | db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
|
|---|
| 243 | db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
|
|---|
| 244 | } else if (result.state === 'expired') {
|
|---|
| 245 | db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
|
|---|
| 246 | }
|
|---|
| 247 | return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
|
|---|
| 248 | }
|
|---|
| 249 |
|
|---|
| 250 | /**
|
|---|
| 251 | * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
|
|---|
| 252 | *
|
|---|
| 253 | * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
|
|---|
| 254 | * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
|
|---|
| 255 | * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
|
|---|
| 256 | * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
|
|---|
| 257 | * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
|
|---|
| 258 | * het gezien.
|
|---|
| 259 | *
|
|---|
| 260 | * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
|
|---|
| 261 | * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
|
|---|
| 262 | * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
|
|---|
| 263 | * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
|
|---|
| 264 | *
|
|---|
| 265 | * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
|
|---|
| 266 | * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
|
|---|
| 267 | * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
|
|---|
| 268 | * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
|
|---|
| 269 | */
|
|---|
| 270 | export function gateConsequence(feature) {
|
|---|
| 271 | const g = GATE_CATALOGUE.find((x) => x.feature === feature);
|
|---|
| 272 | if (!g) return 'unknown';
|
|---|
| 273 | return g.reversible === false ? 'irreversible' : 'reversible';
|
|---|
| 274 | }
|
|---|
| 275 |
|
|---|
| 276 | /**
|
|---|
| 277 | * Zou het antwoord van deze guardian het besluit AFMAKEN (shaer-8vt)?
|
|---|
| 278 | *
|
|---|
| 279 | * De telling is een race naar de drempel: zodra het aantal gehaald is, is het
|
|---|
| 280 | * gevallen. Bij 2 van 3 is de tweede ja dus meteen de beslissing, en bij een
|
|---|
| 281 | * volgverzoek met twee guardians is de EERSTE ja dat al. Wie antwoordt weet dat
|
|---|
| 282 | * niet, en het scherm zei het nergens.
|
|---|
| 283 | *
|
|---|
| 284 | * EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist
|
|---|
| 285 | * mee, veroudert onderweg en leest daarna als een feit; de beschikbare set
|
|---|
| 286 | * schuift bovendien met 3.6 mee. En hoeveel guardians een kind heeft, en wie er
|
|---|
| 287 | * al gestemd heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien.
|
|---|
| 288 | * Een waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.
|
|---|
| 289 | *
|
|---|
| 290 | * BIJ TWIJFEL WAARSCHUWEN. De twee fouten zijn niet gelijk: zeggen dat je
|
|---|
| 291 | * beslist terwijl dat niet zo is maakt iemand voorzichtiger dan nodig; niets
|
|---|
| 292 | * zeggen terwijl hij wel beslist laat hem het onwetend doen.
|
|---|
| 293 | */
|
|---|
| 294 | export function isDecisive(votes, need) {
|
|---|
| 295 | const v = Number.isFinite(votes) ? votes : 0;
|
|---|
| 296 | const n = Number.isFinite(need) ? need : 1;
|
|---|
| 297 | return (n - v) <= 1;
|
|---|
| 298 | }
|
|---|
| 299 |
|
|---|
| 300 | /** The open decision for a feature, for showing progress ("1 of 2"). */
|
|---|
| 301 | export function gatedProgress(slug, feature) {
|
|---|
| 302 | const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 303 | .all(slug, feature);
|
|---|
| 304 | // Progress over the available set (§3.5), like the tally itself.
|
|---|
| 305 | const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
|
|---|
| 306 | return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
|
|---|
| 307 | }
|
|---|
| 308 |
|
|---|
| 309 | // ── The federated shape (§5.6) ────────────────────────────────────
|
|---|
| 310 | // An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
|
|---|
| 311 | // here so both the inbox and the outbox read it the same way.
|
|---|
| 312 |
|
|---|
| 313 | /** Read a shaer:GatedSetting object, or null when this is a different Offer. */
|
|---|
| 314 | export function parseGatedSetting(object) {
|
|---|
| 315 | if (!object || typeof object !== 'object') return null;
|
|---|
| 316 | const type = Array.isArray(object.type) ? object.type[0] : object.type;
|
|---|
| 317 | if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
|
|---|
| 318 | const ward = object['shaer:ward'] || object.ward;
|
|---|
| 319 | const feature = object['shaer:feature'] || object.feature;
|
|---|
| 320 | const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
|
|---|
| 321 | if (typeof ward !== 'string' || typeof feature !== 'string') return null;
|
|---|
| 322 | return { ward, feature, value: value === true || value === 1 || value === 'true' };
|
|---|
| 323 | }
|
|---|
| 324 |
|
|---|
| 325 | /** Build the Offer a guardian sends to the ward's server. */
|
|---|
| 326 | export function buildGatedOffer(offerId, actor, ward, feature, value) {
|
|---|
| 327 | return {
|
|---|
| 328 | id: offerId,
|
|---|
| 329 | type: 'Offer',
|
|---|
| 330 | actor,
|
|---|
| 331 | to: [ward],
|
|---|
| 332 | object: {
|
|---|
| 333 | type: 'shaer:GatedSetting',
|
|---|
| 334 | 'shaer:ward': ward,
|
|---|
| 335 | 'shaer:feature': feature,
|
|---|
| 336 | 'shaer:value': !!value,
|
|---|
| 337 | },
|
|---|
| 338 | };
|
|---|
| 339 | }
|
|---|
| 340 |
|
|---|
| 341 | // ── The guardian-side copy (the missing leg of §5.6) ──────────────
|
|---|
| 342 | // A proposal addressed to the ward's server reaches only the proposer and the
|
|---|
| 343 | // ward. The other guardians never learn it exists, so a threshold of two can
|
|---|
| 344 | // never be met and every proposal expires unanswered. The ward's server
|
|---|
| 345 | // therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
|
|---|
| 346 | // guardian stores a copy it can answer, and the answer travels back to the
|
|---|
| 347 | // ward, which tallies.
|
|---|
| 348 |
|
|---|
| 349 | let _rs = null;
|
|---|
| 350 | function rstmts() {
|
|---|
| 351 | if (!_rs) {
|
|---|
| 352 | _rs = {
|
|---|
| 353 | ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value, decisive)
|
|---|
| 354 | VALUES (?,?,?,?,?,?,?,?)
|
|---|
| 355 | ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox, decisive = excluded.decisive`),
|
|---|
| 356 | get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 357 | bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
|
|---|
| 358 | del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 359 | delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
|
|---|
| 360 | };
|
|---|
| 361 | }
|
|---|
| 362 | return _rs;
|
|---|
| 363 | }
|
|---|
| 364 |
|
|---|
| 365 | export function recordGatedReview(guardianSlug, r) {
|
|---|
| 366 | // decisive ontbreekt bij een oudere server -> 1, want bij twijfel waarschuwen.
|
|---|
| 367 | rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0, r.decisive === false ? 0 : 1);
|
|---|
| 368 | return rstmts().get.get(guardianSlug, r.id);
|
|---|
| 369 | }
|
|---|
| 370 | export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
|
|---|
| 371 | export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
|
|---|
| 372 | export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
|
|---|
| 373 | /** Drop every guardian's copy once the decision has settled or lapsed. */
|
|---|
| 374 | export function clearGatedReviews(id) { rstmts().delAll.run(id); }
|
|---|
| 375 |
|
|---|
| 376 | export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
|
|---|
| 377 | try {
|
|---|
| 378 | db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
|
|---|
| 379 | .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
|
|---|
| 380 | } catch { /* non-fatal */ }
|
|---|
| 381 | }
|
|---|
| 382 |
|
|---|
| 383 | export function recallGatedOffer(offerId) {
|
|---|
| 384 | try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
|
|---|
| 385 | catch { return null; }
|
|---|
| 386 | }
|
|---|
| 387 |
|
|---|
| 388 | // ── The proposer's own record (5.6) ───────────────────────────────
|
|---|
| 389 | // "Where did my proposal go?" had no answer: the status was a button caption
|
|---|
| 390 | // that did not survive a refresh. The ward's server tallies elsewhere, so the
|
|---|
| 391 | // proposer keeps its own row and the ward's server ANSWERS the Offer when the
|
|---|
| 392 | // decision settles: Accept when it settled on the proposed value, Reject when
|
|---|
| 393 | // it settled on the opposite. An open row past the window renders as expired,
|
|---|
| 394 | // because an expired decision settles on nothing and nobody writes home.
|
|---|
| 395 |
|
|---|
| 396 | export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
|
|---|
| 397 | try {
|
|---|
| 398 | db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
|
|---|
| 399 | VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
|
|---|
| 400 | } catch { /* non-fatal */ }
|
|---|
| 401 | }
|
|---|
| 402 |
|
|---|
| 403 | export function recallSent(offerId) {
|
|---|
| 404 | try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
|
|---|
| 405 | catch { return null; }
|
|---|
| 406 | }
|
|---|
| 407 |
|
|---|
| 408 | /**
|
|---|
| 409 | * De stand van een gate zoals DEZE guardian hem kent.
|
|---|
| 410 | *
|
|---|
| 411 | * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
|
|---|
| 412 | * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
|
|---|
| 413 | * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
|
|---|
| 414 | * true betekent dat de poort openging.
|
|---|
| 415 | *
|
|---|
| 416 | * Geeft { value, decided }:
|
|---|
| 417 | * decided true we hebben een aangenomen besluit gezien; value is die waarde
|
|---|
| 418 | * decided false we hebben er geen; value is de standaard voor een ward (uit)
|
|---|
| 419 | *
|
|---|
| 420 | * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
|
|---|
| 421 | * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
|
|---|
| 422 | *
|
|---|
| 423 | * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
|
|---|
| 424 | * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
|
|---|
| 425 | * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
|
|---|
| 426 | * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
|
|---|
| 427 | * onveilige kant en het hoort gerepareerd te worden.
|
|---|
| 428 | */
|
|---|
| 429 | export function knownSetting(guardianSlug, wardUri, feature) {
|
|---|
| 430 | try {
|
|---|
| 431 | const r = db.prepare(`SELECT value FROM ap_gated_sent
|
|---|
| 432 | WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
|
|---|
| 433 | ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
|
|---|
| 434 | if (r) return { value: !!r.value, decided: true };
|
|---|
| 435 | } catch { /* val terug op de standaard */ }
|
|---|
| 436 | return { value: false, decided: false };
|
|---|
| 437 | }
|
|---|
| 438 |
|
|---|
| 439 | export function settleSent(offerId, outcome) {
|
|---|
| 440 | try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
|
|---|
| 441 | }
|
|---|
| 442 |
|
|---|
| 443 | /** The latest proposal per feature this guardian sent to this ward. */
|
|---|
| 444 | export function listSent(guardianSlug, wardUri) {
|
|---|
| 445 | try {
|
|---|
| 446 | return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
|
|---|
| 447 | GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
|
|---|
| 448 | } catch { return []; }
|
|---|
| 449 | }
|
|---|
| 450 |
|
|---|
| 451 | /**
|
|---|
| 452 | * What a sent row means on a screen. Pure, so the rule is testable: an answer
|
|---|
| 453 | * wins, and silence past the window is not "still running", it is over.
|
|---|
| 454 | */
|
|---|
| 455 | export function sentStatus(row, now) {
|
|---|
| 456 | if (!row) return null;
|
|---|
| 457 | if (row.status === 'accepted' || row.status === 'rejected') return row.status;
|
|---|
| 458 | const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
|
|---|
| 459 | if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
|
|---|
| 460 | return 'open';
|
|---|
| 461 | }
|
|---|
| 462 |
|
|---|
| 463 | export default {
|
|---|
| 464 | GATE_CATALOGUE, gateRows, knownSetting,
|
|---|
| 465 | tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, isDecisive, GATED_WINDOW_MS,
|
|---|
| 466 | parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
|
|---|
| 467 | recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
|
|---|
| 468 | recordSent, recallSent, settleSent, listSent, sentStatus,
|
|---|
| 469 | };
|
|---|