| 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 | };
|
|---|
| 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 | */
|
|---|
| 80 | export 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 | */
|
|---|
| 124 | export 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 |
|
|---|
| 162 | export 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 | */
|
|---|
| 170 | export 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 | /**
|
|---|
| 210 | * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
|
|---|
| 211 | *
|
|---|
| 212 | * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
|
|---|
| 213 | * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
|
|---|
| 214 | * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
|
|---|
| 215 | * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
|
|---|
| 216 | * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
|
|---|
| 217 | * het gezien.
|
|---|
| 218 | *
|
|---|
| 219 | * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
|
|---|
| 220 | * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
|
|---|
| 221 | * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
|
|---|
| 222 | * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
|
|---|
| 223 | *
|
|---|
| 224 | * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
|
|---|
| 225 | * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
|
|---|
| 226 | * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
|
|---|
| 227 | * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
|
|---|
| 228 | */
|
|---|
| 229 | export function gateConsequence(feature) {
|
|---|
| 230 | const g = GATE_CATALOGUE.find((x) => x.feature === feature);
|
|---|
| 231 | if (!g) return 'unknown';
|
|---|
| 232 | return g.reversible === false ? 'irreversible' : 'reversible';
|
|---|
| 233 | }
|
|---|
| 234 |
|
|---|
| 235 | /** The open decision for a feature, for showing progress ("1 of 2"). */
|
|---|
| 236 | export function gatedProgress(slug, feature) {
|
|---|
| 237 | const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 238 | .all(slug, feature);
|
|---|
| 239 | // Progress over the available set (§3.5), like the tally itself.
|
|---|
| 240 | const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
|
|---|
| 241 | return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
|
|---|
| 242 | }
|
|---|
| 243 |
|
|---|
| 244 | // ── The federated shape (§5.6) ────────────────────────────────────
|
|---|
| 245 | // An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
|
|---|
| 246 | // here so both the inbox and the outbox read it the same way.
|
|---|
| 247 |
|
|---|
| 248 | /** Read a shaer:GatedSetting object, or null when this is a different Offer. */
|
|---|
| 249 | export function parseGatedSetting(object) {
|
|---|
| 250 | if (!object || typeof object !== 'object') return null;
|
|---|
| 251 | const type = Array.isArray(object.type) ? object.type[0] : object.type;
|
|---|
| 252 | if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
|
|---|
| 253 | const ward = object['shaer:ward'] || object.ward;
|
|---|
| 254 | const feature = object['shaer:feature'] || object.feature;
|
|---|
| 255 | const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
|
|---|
| 256 | if (typeof ward !== 'string' || typeof feature !== 'string') return null;
|
|---|
| 257 | return { ward, feature, value: value === true || value === 1 || value === 'true' };
|
|---|
| 258 | }
|
|---|
| 259 |
|
|---|
| 260 | /** Build the Offer a guardian sends to the ward's server. */
|
|---|
| 261 | export function buildGatedOffer(offerId, actor, ward, feature, value) {
|
|---|
| 262 | return {
|
|---|
| 263 | id: offerId,
|
|---|
| 264 | type: 'Offer',
|
|---|
| 265 | actor,
|
|---|
| 266 | to: [ward],
|
|---|
| 267 | object: {
|
|---|
| 268 | type: 'shaer:GatedSetting',
|
|---|
| 269 | 'shaer:ward': ward,
|
|---|
| 270 | 'shaer:feature': feature,
|
|---|
| 271 | 'shaer:value': !!value,
|
|---|
| 272 | },
|
|---|
| 273 | };
|
|---|
| 274 | }
|
|---|
| 275 |
|
|---|
| 276 | // ── The guardian-side copy (the missing leg of §5.6) ──────────────
|
|---|
| 277 | // A proposal addressed to the ward's server reaches only the proposer and the
|
|---|
| 278 | // ward. The other guardians never learn it exists, so a threshold of two can
|
|---|
| 279 | // never be met and every proposal expires unanswered. The ward's server
|
|---|
| 280 | // therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
|
|---|
| 281 | // guardian stores a copy it can answer, and the answer travels back to the
|
|---|
| 282 | // ward, which tallies.
|
|---|
| 283 |
|
|---|
| 284 | let _rs = null;
|
|---|
| 285 | function rstmts() {
|
|---|
| 286 | if (!_rs) {
|
|---|
| 287 | _rs = {
|
|---|
| 288 | ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
|
|---|
| 289 | VALUES (?,?,?,?,?,?,?)
|
|---|
| 290 | ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
|
|---|
| 291 | get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 292 | bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
|
|---|
| 293 | del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 294 | delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
|
|---|
| 295 | };
|
|---|
| 296 | }
|
|---|
| 297 | return _rs;
|
|---|
| 298 | }
|
|---|
| 299 |
|
|---|
| 300 | export function recordGatedReview(guardianSlug, r) {
|
|---|
| 301 | rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
|
|---|
| 302 | return rstmts().get.get(guardianSlug, r.id);
|
|---|
| 303 | }
|
|---|
| 304 | export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
|
|---|
| 305 | export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
|
|---|
| 306 | export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
|
|---|
| 307 | /** Drop every guardian's copy once the decision has settled or lapsed. */
|
|---|
| 308 | export function clearGatedReviews(id) { rstmts().delAll.run(id); }
|
|---|
| 309 |
|
|---|
| 310 | export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
|
|---|
| 311 | try {
|
|---|
| 312 | db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
|
|---|
| 313 | .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
|
|---|
| 314 | } catch { /* non-fatal */ }
|
|---|
| 315 | }
|
|---|
| 316 |
|
|---|
| 317 | export function recallGatedOffer(offerId) {
|
|---|
| 318 | try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
|
|---|
| 319 | catch { return null; }
|
|---|
| 320 | }
|
|---|
| 321 |
|
|---|
| 322 | // ── The proposer's own record (5.6) ───────────────────────────────
|
|---|
| 323 | // "Where did my proposal go?" had no answer: the status was a button caption
|
|---|
| 324 | // that did not survive a refresh. The ward's server tallies elsewhere, so the
|
|---|
| 325 | // proposer keeps its own row and the ward's server ANSWERS the Offer when the
|
|---|
| 326 | // decision settles: Accept when it settled on the proposed value, Reject when
|
|---|
| 327 | // it settled on the opposite. An open row past the window renders as expired,
|
|---|
| 328 | // because an expired decision settles on nothing and nobody writes home.
|
|---|
| 329 |
|
|---|
| 330 | export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
|
|---|
| 331 | try {
|
|---|
| 332 | db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
|
|---|
| 333 | VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
|
|---|
| 334 | } catch { /* non-fatal */ }
|
|---|
| 335 | }
|
|---|
| 336 |
|
|---|
| 337 | export function recallSent(offerId) {
|
|---|
| 338 | try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
|
|---|
| 339 | catch { return null; }
|
|---|
| 340 | }
|
|---|
| 341 |
|
|---|
| 342 | /**
|
|---|
| 343 | * De stand van een gate zoals DEZE guardian hem kent.
|
|---|
| 344 | *
|
|---|
| 345 | * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
|
|---|
| 346 | * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
|
|---|
| 347 | * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
|
|---|
| 348 | * true betekent dat de poort openging.
|
|---|
| 349 | *
|
|---|
| 350 | * Geeft { value, decided }:
|
|---|
| 351 | * decided true we hebben een aangenomen besluit gezien; value is die waarde
|
|---|
| 352 | * decided false we hebben er geen; value is de standaard voor een ward (uit)
|
|---|
| 353 | *
|
|---|
| 354 | * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
|
|---|
| 355 | * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
|
|---|
| 356 | *
|
|---|
| 357 | * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
|
|---|
| 358 | * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
|
|---|
| 359 | * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
|
|---|
| 360 | * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
|
|---|
| 361 | * onveilige kant en het hoort gerepareerd te worden.
|
|---|
| 362 | */
|
|---|
| 363 | export function knownSetting(guardianSlug, wardUri, feature) {
|
|---|
| 364 | try {
|
|---|
| 365 | const r = db.prepare(`SELECT value FROM ap_gated_sent
|
|---|
| 366 | WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
|
|---|
| 367 | ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
|
|---|
| 368 | if (r) return { value: !!r.value, decided: true };
|
|---|
| 369 | } catch { /* val terug op de standaard */ }
|
|---|
| 370 | return { value: false, decided: false };
|
|---|
| 371 | }
|
|---|
| 372 |
|
|---|
| 373 | export function settleSent(offerId, outcome) {
|
|---|
| 374 | try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
|
|---|
| 375 | }
|
|---|
| 376 |
|
|---|
| 377 | /** The latest proposal per feature this guardian sent to this ward. */
|
|---|
| 378 | export function listSent(guardianSlug, wardUri) {
|
|---|
| 379 | try {
|
|---|
| 380 | return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
|
|---|
| 381 | GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
|
|---|
| 382 | } catch { return []; }
|
|---|
| 383 | }
|
|---|
| 384 |
|
|---|
| 385 | /**
|
|---|
| 386 | * What a sent row means on a screen. Pure, so the rule is testable: an answer
|
|---|
| 387 | * wins, and silence past the window is not "still running", it is over.
|
|---|
| 388 | */
|
|---|
| 389 | export function sentStatus(row, now) {
|
|---|
| 390 | if (!row) return null;
|
|---|
| 391 | if (row.status === 'accepted' || row.status === 'rejected') return row.status;
|
|---|
| 392 | const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
|
|---|
| 393 | if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
|
|---|
| 394 | return 'open';
|
|---|
| 395 | }
|
|---|
| 396 |
|
|---|
| 397 | export default {
|
|---|
| 398 | GATE_CATALOGUE, gateRows, knownSetting,
|
|---|
| 399 | tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, GATED_WINDOW_MS,
|
|---|
| 400 | parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
|
|---|
| 401 | recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
|
|---|
| 402 | recordSent, recallSent, settleSent, listSent, sentStatus,
|
|---|
| 403 | };
|
|---|