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

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

Waarschuwen voor wat er blijft hangen (shaer-nf9)

Barts opdracht: waarschuw bij het openen en het accepteren van een gate-voorstel.
Zijn zin was "Een geopende poort gaat niet meer dicht!"

DIE ZIN STAAT ER NIET, EN DAT IS MET OPZET. Als uitspraak over de INSTELLING is
hij onwaar: shaer-ahy eist het tegendeel en de code doet het -- een voorstel
draagt true of false. Een waarschuwing die aantoonbaar niet klopt neemt de rest
van het scherm mee in zijn val zodra iemand het merkt.

Wat wel onomkeerbaar is, is het GEVOLG, en die versie is zwaarder:

Wat hier doorheen komt, komt niet meer terug. Je kunt deze instelling later
weer dichtzetten -- wat je kind gezien heeft niet.

Independence krijgt zijn eigen tekst, want dat draagt gezag over en is wel echt
onomkeerbaar (shaer-90v). Welke tekst je krijgt volgt uit de catalogus, dus een
nieuwe gate hoeft niets te bedenken.

ONBEKEND KRIJGT DE ZWAARSTE. Een guardian elders kan iets voorstellen dat onze
catalogus niet kent. Bij twijfel waarschuwen we zwaarder, niet lichter -- de
faalstand die pijn doet is een guardian die iets doorlaat omdat het scherm er
licht over deed.

ALLEEN BIJ OPENZETTEN. Dichtzetten laat niets nieuws door, en een waarschuwing
die overal staat wordt nergens gelezen.

BIJ ACCEPTEREN KON HET GEVRAAGDE NIET. De bead stelde voor te tonen of jouw
antwoord de drempel haalt, want gatedProgress bestaat al. Maar die werkt op een
LOKALE slug, en er zijn geen lokale wards -- voor deze kaart komt hij altijd op
null uit. Een verzonnen "1 van 2" zou hier het gevaarlijkste getal op het scherm
zijn: het leest als "er kan nog iemand na mij". Er staat nu wat waar is: wij zien
de telling niet, die loopt op de server van het kind, en jouw ja kan de doorslag
geven -- want de tally settelt op het moment dat de drempel gehaald is.

Geen window.confirm, zoals bij het loslaten van een ward: de uitleg staat er, en
dan pas de knop. Drie talen. Vier toetsen, twee vallen om bij mutatie. 619/619.

  • Property mode set to 100644
File size: 20.6 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/**
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 */
229export 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"). */
236export 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. */
249export 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. */
261export 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
284let _rs = null;
285function 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
300export 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}
304export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
305export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
306export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
307/** Drop every guardian's copy once the decision has settled or lapsed. */
308export function clearGatedReviews(id) { rstmts().delAll.run(id); }
309
310export 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
317export 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
330export 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
337export 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 */
363export 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
373export 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. */
378export 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 */
389export 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
397export 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};
Note: See TracBrowser for help on using the repository browser.