source: Klonkt/src/services/guardianship/gated.js@ 6100ce9

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

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

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

  • Property mode set to 100644
File size: 9.7 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) {
185 try {
186 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value) VALUES (?,?,?,?)')
187 .run(offerId, slug, feature, value ? 1 : 0);
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
196export default {
197 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
198 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
199 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
200};
Note: See TracBrowser for help on using the repository browser.