source: Klonkt/src/services/guardianship/gated.js@ d56d471

main
Last change on this file since d56d471 was d56d471, checked in by Robin <roboburr@…>, 6 weeks ago

Een voorstel krijgt een antwoord, een status en een zichtbare kring

Robin meldde drie dingen na de voorstelronde: er lijkt niets te gebeuren, de
status van een voorstel is nergens te volgen, en in /guardian zie je niet wie
de mede-guardians van een kind zijn. Onderzoek op beta wees de kern aan: de
ronde is aangekomen en geteld, maar elk voorstel was shaer:externalEmbeds, en
die stond al aan. Afspelen is nooit voorgesteld, en dat KON ook niet: de knop
"Afspelen voorstellen" verscheen alleen bij embeds === true, en voor een ward
op een andere server is die waarde onbekend, dus null, dus geen knop. Onbekend
is niet uit.

Drie reparaties, een per klacht.

Een: de lus sluit. De server van het kind beantwoordt de Offer die de
beslissing opende, terug naar de voorsteller: Accept als hij settelde op het
voorgestelde, Reject bij het tegendeel. Alleen de stem van het kind-account
telt als uitkomst; een vreemde die onze boeken wil sluiten wordt genegeerd.
Zonder dit kon het scherm van de voorsteller alleen maar eeuwig "wacht"
zeggen, wat er ook gebeurd was: de telling is het prive-grootboek van de
server van het kind.

Twee: de voorsteller houdt een eigen boek bij (ap_gated_sent) en het paneel
toont per ward de stand: wacht, aangenomen, afgewezen, of eerlijk verlopen als
het venster leegliep. Stilte na het venster is geen "loopt nog".

Drie: voor een ward op een andere server toont het paneel nu wel wie de
guardians zijn. Het lidmaatschap is publiek op het actordocument
(shaer:guardians, 2.1); de beschikbaarheid is en blijft het prive-grootboek
van hun server (3.6.1) en wordt alleen benoemd, niet getoond.

Changed files:
src/config/database.js

  • tabel ap_gated_sent (het boek van de voorsteller)
  • kolom proposer op ap_gated_offers, voor het antwoord naar huis

src/services/guardianship/gated.js

  • recordSent/recallSent/settleSent/listSent en sentStatus (puur, getest)
  • rememberGatedOffer onthoudt de voorsteller

src/services/guardianship/handshake.js

  • answerGatedProposer: het settle-antwoord naar de voorsteller
  • de inbox herkent dat antwoord en settelt het eigen boek; alleen het kind-account mag dat

src/routes/guardian.js

  • proposeGated schrijft het boek; dashboardState serveert per ward de voorstellen met status
  • GET /wards/guardians: lokaal met beschikbaarheid, remote de publieke lijst van hun actordocument

src/assets/js/guardian.js

  • de afspeel-knop verschijnt ook bij onbekende embeds-stand (de bug)
  • statusregels onder de instellingen-knoppen
  • remote guardians opgehaald bij het openen van het paneel

src/assets/css/guardian.css

  • g-prop-kleuren: de staat spreekt voor de woorden uit

src/services/i18n.js

  • prop_*- en panel_guards_far-strings, NL/EN/DE

test/gated-settings.test.js

  • de lus sluit: de voorsteller krijgt het antwoord, van alleen het kind
  • het boek: open, aangenomen, afgewezen, verlopen

remarks: 334 tests groen, server start op een schone database. De guardian-kant
(sound-fabrics) en de ward-kant (beta) hebben allebei deze commit nodig: de
knop en de status leven bij de guardian, het antwoord bij het kind.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 11.9 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};
62export function featureColumn(feature) {
63 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
64}
65
66/**
67 * Record one guardian's answer and settle if the threshold is now reached.
68 * Returns the tally state so a caller can report it.
69 */
70export function recordGatedVote(slug, feature, guardianUri, value) {
71 const column = featureColumn(feature);
72 if (!column) return { state: 'expired', error: 'unknown_feature' };
73 const all = listGuardians(slug).map((g) => g.other_uri);
74 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
75 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
76 // restored first, so it always counts itself back into the set below.
77 availability.oneAnswer(guardianUri, Date.now());
78 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
79 // against the full list above: any guardian may answer, and answering is
80 // exactly what brings it back in.
81 const guardians = availability.availableSet(slug, all, Date.now());
82
83 // The window opens with the first answer, and a stale decision starts over:
84 // a proposal from last month should not silently count toward today's.
85 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
86 .get(slug, feature);
87 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
88 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
89 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
90 openedAt = Date.now();
91 }
92 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
93 VALUES (?,?,?,?,?)
94 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
95 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
96
97 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
98 .all(slug, feature);
99 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
100 if (result.state === 'settled') {
101 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
102 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
103 } else if (result.state === 'expired') {
104 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
105 }
106 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
107}
108
109/** The open decision for a feature, for showing progress ("1 of 2"). */
110export function gatedProgress(slug, feature) {
111 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
112 .all(slug, feature);
113 // Progress over the available set (§3.5), like the tally itself.
114 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
115 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
116}
117
118// ── The federated shape (§5.6) ────────────────────────────────────
119// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
120// here so both the inbox and the outbox read it the same way.
121
122/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
123export function parseGatedSetting(object) {
124 if (!object || typeof object !== 'object') return null;
125 const type = Array.isArray(object.type) ? object.type[0] : object.type;
126 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
127 const ward = object['shaer:ward'] || object.ward;
128 const feature = object['shaer:feature'] || object.feature;
129 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
130 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
131 return { ward, feature, value: value === true || value === 1 || value === 'true' };
132}
133
134/** Build the Offer a guardian sends to the ward's server. */
135export function buildGatedOffer(offerId, actor, ward, feature, value) {
136 return {
137 id: offerId,
138 type: 'Offer',
139 actor,
140 to: [ward],
141 object: {
142 type: 'shaer:GatedSetting',
143 'shaer:ward': ward,
144 'shaer:feature': feature,
145 'shaer:value': !!value,
146 },
147 };
148}
149
150// ── The guardian-side copy (the missing leg of §5.6) ──────────────
151// A proposal addressed to the ward's server reaches only the proposer and the
152// ward. The other guardians never learn it exists, so a threshold of two can
153// never be met and every proposal expires unanswered. The ward's server
154// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
155// guardian stores a copy it can answer, and the answer travels back to the
156// ward, which tallies.
157
158let _rs = null;
159function rstmts() {
160 if (!_rs) {
161 _rs = {
162 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
163 VALUES (?,?,?,?,?,?,?)
164 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
165 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
166 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
167 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
168 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
169 };
170 }
171 return _rs;
172}
173
174export function recordGatedReview(guardianSlug, r) {
175 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
176 return rstmts().get.get(guardianSlug, r.id);
177}
178export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
179export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
180export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
181/** Drop every guardian's copy once the decision has settled or lapsed. */
182export function clearGatedReviews(id) { rstmts().delAll.run(id); }
183
184export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
185 try {
186 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
187 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
188 } catch { /* non-fatal */ }
189}
190
191export function recallGatedOffer(offerId) {
192 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
193 catch { return null; }
194}
195
196// ── The proposer's own record (5.6) ───────────────────────────────
197// "Where did my proposal go?" had no answer: the status was a button caption
198// that did not survive a refresh. The ward's server tallies elsewhere, so the
199// proposer keeps its own row and the ward's server ANSWERS the Offer when the
200// decision settles: Accept when it settled on the proposed value, Reject when
201// it settled on the opposite. An open row past the window renders as expired,
202// because an expired decision settles on nothing and nobody writes home.
203
204export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
205 try {
206 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
207 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
208 } catch { /* non-fatal */ }
209}
210
211export function recallSent(offerId) {
212 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
213 catch { return null; }
214}
215
216export function settleSent(offerId, outcome) {
217 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
218}
219
220/** The latest proposal per feature this guardian sent to this ward. */
221export function listSent(guardianSlug, wardUri) {
222 try {
223 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
224 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
225 } catch { return []; }
226}
227
228/**
229 * What a sent row means on a screen. Pure, so the rule is testable: an answer
230 * wins, and silence past the window is not "still running", it is over.
231 */
232export function sentStatus(row, now) {
233 if (!row) return null;
234 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
235 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
236 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
237 return 'open';
238}
239
240export default {
241 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
242 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
243 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
244 recordSent, recallSent, settleSent, listSent, sentStatus,
245};
Note: See TracBrowser for help on using the repository browser.