| [65abc85] | 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 |
|
|---|
| 18 | /** The window a gated-setting decision stays open. Reversible, so a day. */
|
|---|
| 19 | export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|---|
| 20 |
|
|---|
| 21 | /** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
|
|---|
| 22 | export function thresholdFor(setSize) {
|
|---|
| 23 | return Math.floor(setSize / 2) + 1;
|
|---|
| 24 | }
|
|---|
| 25 |
|
|---|
| 26 | /**
|
|---|
| 27 | * Tally one decision. Pure, so the rule can be tested without a database.
|
|---|
| 28 | *
|
|---|
| 29 | * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
|
|---|
| 30 | * @param {string[]} guardianSet the guardians at the moment the decision opened
|
|---|
| 31 | * @param {number} ageMs how long the decision has been open
|
|---|
| 32 | * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
|
|---|
| 33 | */
|
|---|
| 34 | export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
|
|---|
| 35 | const set = new Set((guardianSet || []).filter(Boolean));
|
|---|
| 36 | if (!set.size) return { state: 'expired' }; // nobody may decide
|
|---|
| 37 | const need = thresholdFor(set.size);
|
|---|
| 38 | // Only answers from the snapshotted set count, one per guardian.
|
|---|
| 39 | const seen = new Map();
|
|---|
| 40 | for (const v of (votes || [])) {
|
|---|
| 41 | if (!set.has(v.guardian_uri)) continue;
|
|---|
| 42 | seen.set(v.guardian_uri, v.value === true || v.value === 1);
|
|---|
| 43 | }
|
|---|
| 44 | const yes = [...seen.values()].filter(Boolean).length;
|
|---|
| 45 | const no = seen.size - yes;
|
|---|
| 46 | // Race to the threshold, in both directions: settle the moment it is reached,
|
|---|
| 47 | // and give up the moment it can no longer be reached.
|
|---|
| 48 | if (yes >= need) return { state: 'settled', value: true };
|
|---|
| 49 | if (no >= need) return { state: 'settled', value: false };
|
|---|
| 50 | const undecided = set.size - seen.size;
|
|---|
| 51 | if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
|
|---|
| 52 | if (ageMs >= windowMs) return { state: 'expired' }; // fails closed
|
|---|
| 53 | return { state: 'open' };
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| 56 | /** The column a feature maps onto. Unknown features are refused, not guessed. */
|
|---|
| 57 | const FEATURES = { 'shaer:externalEmbeds': 'external_embeds' };
|
|---|
| 58 | export function featureColumn(feature) {
|
|---|
| 59 | return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
|
|---|
| 60 | }
|
|---|
| 61 |
|
|---|
| 62 | /**
|
|---|
| 63 | * Record one guardian's answer and settle if the threshold is now reached.
|
|---|
| 64 | * Returns the tally state so a caller can report it.
|
|---|
| 65 | */
|
|---|
| 66 | export function recordGatedVote(slug, feature, guardianUri, value) {
|
|---|
| 67 | const column = featureColumn(feature);
|
|---|
| 68 | if (!column) return { state: 'expired', error: 'unknown_feature' };
|
|---|
| 69 | const guardians = listGuardians(slug).map((g) => g.other_uri);
|
|---|
| 70 | if (!guardians.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
|
|---|
| 71 |
|
|---|
| 72 | // The window opens with the first answer, and a stale decision starts over:
|
|---|
| 73 | // a proposal from last month should not silently count toward today's.
|
|---|
| 74 | const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 75 | .get(slug, feature);
|
|---|
| 76 | let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
|
|---|
| 77 | if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
|
|---|
| 78 | db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
|
|---|
| 79 | openedAt = Date.now();
|
|---|
| 80 | }
|
|---|
| 81 | db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
|
|---|
| 82 | VALUES (?,?,?,?,?)
|
|---|
| 83 | ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
|
|---|
| 84 | .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
|
|---|
| 85 |
|
|---|
| 86 | const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 87 | .all(slug, feature);
|
|---|
| 88 | const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
|
|---|
| 89 | if (result.state === 'settled') {
|
|---|
| 90 | db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
|
|---|
| 91 | db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
|
|---|
| 92 | } else if (result.state === 'expired') {
|
|---|
| 93 | db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
|
|---|
| 94 | }
|
|---|
| 95 | return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | /** The open decision for a feature, for showing progress ("1 of 2"). */
|
|---|
| 99 | export function gatedProgress(slug, feature) {
|
|---|
| 100 | const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 101 | .all(slug, feature);
|
|---|
| 102 | const guardians = listGuardians(slug).map((g) => g.other_uri);
|
|---|
| 103 | return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
|
|---|
| 104 | }
|
|---|
| 105 |
|
|---|
| 106 | // ── The federated shape (§5.6) ────────────────────────────────────
|
|---|
| 107 | // An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
|
|---|
| 108 | // here so both the inbox and the outbox read it the same way.
|
|---|
| 109 |
|
|---|
| 110 | /** Read a shaer:GatedSetting object, or null when this is a different Offer. */
|
|---|
| 111 | export function parseGatedSetting(object) {
|
|---|
| 112 | if (!object || typeof object !== 'object') return null;
|
|---|
| 113 | const type = Array.isArray(object.type) ? object.type[0] : object.type;
|
|---|
| 114 | if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
|
|---|
| 115 | const ward = object['shaer:ward'] || object.ward;
|
|---|
| 116 | const feature = object['shaer:feature'] || object.feature;
|
|---|
| 117 | const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
|
|---|
| 118 | if (typeof ward !== 'string' || typeof feature !== 'string') return null;
|
|---|
| 119 | return { ward, feature, value: value === true || value === 1 || value === 'true' };
|
|---|
| 120 | }
|
|---|
| 121 |
|
|---|
| 122 | /** Build the Offer a guardian sends to the ward's server. */
|
|---|
| 123 | export function buildGatedOffer(offerId, actor, ward, feature, value) {
|
|---|
| 124 | return {
|
|---|
| 125 | id: offerId,
|
|---|
| 126 | type: 'Offer',
|
|---|
| 127 | actor,
|
|---|
| 128 | to: [ward],
|
|---|
| 129 | object: {
|
|---|
| 130 | type: 'shaer:GatedSetting',
|
|---|
| 131 | 'shaer:ward': ward,
|
|---|
| 132 | 'shaer:feature': feature,
|
|---|
| 133 | 'shaer:value': !!value,
|
|---|
| 134 | },
|
|---|
| 135 | };
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | export function rememberGatedOffer(offerId, slug, feature, value) {
|
|---|
| 139 | try {
|
|---|
| 140 | db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value) VALUES (?,?,?,?)')
|
|---|
| 141 | .run(offerId, slug, feature, value ? 1 : 0);
|
|---|
| 142 | } catch { /* non-fatal */ }
|
|---|
| 143 | }
|
|---|
| 144 |
|
|---|
| 145 | export function recallGatedOffer(offerId) {
|
|---|
| 146 | try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
|
|---|
| 147 | catch { return null; }
|
|---|
| 148 | }
|
|---|
| 149 |
|
|---|
| 150 | export default {
|
|---|
| 151 | tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
|
|---|
| 152 | parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
|
|---|
| 153 | };
|
|---|