source: Klonkt/src/routes/guardian.js@ 72d8eff

main
Last change on this file since 72d8eff was 72d8eff, checked in by roboburr <roboburr@โ€ฆ>, 5 weeks ago

De gate-stand komt uit de besluiten, want er zijn geen lokale accounts

Barts correctie, en hij raakt twee dingen die ik fout had.

ER ZIJN GEEN LOKALE ACCOUNTS. wardGateSetting() leest de kolom alleen als de ward
op onze eigen base staat, en met een Klonkt per gebruiker woont elke ward elders.
Die functie gaf dus voor IEDERE ward null, en het gate-paneel toonde overal
"onbekend". Ik noemde dat een randgeval terwijl het het enige geval is.

EN DE GUARDIAN WEET HET WEL: hij kreeg de uitslag van het besluit door.
ap_gated_sent bewaart per voorstel de feature, de waarde en de uitkomst, dus een
geaccepteerd voorstel met waarde true IS de stand. knownSetting() leest dat.

Drie standen in plaats van twee. "Uit" en "voor zover wij weten uit" zijn niet
hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat hoort
niet te lezen als een genomen besluit. De rij draagt nu decided en het scherm
zegt "uit (nog niets over besloten)".

Daarmee vervalt ook het gat dat ik een commit eerder noteerde: de richting van de
voorstelknop volgt nu een BEKENDE stand, dus voor een ward elders kun je ook
DICHTZETTEN voorstellen. Dat was precies de veilige richting die ontbrak.

De trap blokkeert alleen op een BESLOTEN dicht, niet op de standaard. Anders kan
afspelen nooit als eerste voorgesteld worden, en zo ging er ooit een hele
voorstelronde de verkeerde gate in.

BEKEND GAT, in het commentaar bij knownSetting gezet en niet hier opgelost: dit
ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van een mede-guardian,
dan komt de uitslag wel binnen (gated_outcome) maar wordt hij niet bewaard --
handshake.js legt alleen vast voor sent-rijen die van ons zijn. Een gate die een
ander heeft geopend leest hier dus als uit, en dat is de onveilige kant.

4 tests erbij, waaronder de terugval op de oude kale-boolean vorm. Suite 599/599.

  • Property mode set to 100644
File size: 39.7 KB
Lineย 
1/**
2 * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
3 * guardians. One place to add and manage wards, a message centre for
4 * incoming help requests and adoption traffic, and its own push channel
5 * (alert types 'help' and 'guardian', web-push slice reused).
6 *
7 * Everything is scoped to a site the logged-in user OWNS: the guardian acts
8 * as one of their own actors (?site=slug picks one when they own several).
9 * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
10 */
11import express from 'express';
12import path from 'path';
13import { fileURLToPath } from 'url';
14import db from '../config/database.js';
15import { requireAuth } from '../middleware/auth.js';
16import AP from '../services/ActivityPubService.js';
17import * as Guardianship from '../services/guardianship/index.js';
18import { t as i18nT, resolveLang } from '../services/i18n.js';
19import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
20import { emojiName } from '../services/NoteRender.js';
21
22const router = express.Router();
23const __dir = path.dirname(fileURLToPath(import.meta.url));
24
25/** The acting site: ?site=slug when owned, else the user's first site. */
26function siteForUser(req) {
27 const userId = req.session.user.id;
28 const want = String(req.query.site || req.body?.site || '').trim();
29 if (want) {
30 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
31 if (s) return s;
32 }
33 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
34}
35
36/** Everything the dashboard shows, one shape for page and API. */
37function uiStrings(L) {
38 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
39 'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
40 'embeds_on', 'embeds_off', 'embeds_propose', 'embeds_waiting',
41 'accept', 'reject', 'complete', 'awaiting_others', 'coguard',
42 // The per-ward panel: everything about one child in one place.
43 'settings_title', 'panel_open', 'panel_close', 'panel_help', 'panel_help_empty',
44 'panel_follow', 'panel_follow_empty', 'panel_posts', 'panel_posts_empty',
45 'panel_actions', 'badge_help', 'badge_follow', 'badge_follow_one', 'help_empty',
46 // Releasing a ward: a deliberate two-step answer, never one click.
47 'release_title', 'release_effect', 'release_local', 'release_step_down',
48 'release_last', 'release_unknown', 'release_yes', 'release_no',
49 // Availability (FEP-633c 3.6): the dots, the step-away, the lapse.
50 'avail_available', 'avail_away', 'avail_dormant', 'panel_guards', 'panel_guards_remote',
51 'lapse_propose', 'lapse_line', 'lapse_tally', 'lapse_note', 'lapse_agree', 'lapse_disagree', 'voted',
52 'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
53 // A gated-setting proposal from a fellow guardian (5.6).
54 'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree',
55 'play_propose', 'play_on', 'play_off',
56 // The status of a proposal this guardian sent (5.6).
57 'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
58 'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
59 'panel_guards_far',
60 // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
61 // de drempel erbij. De namen volgen de catalogus in gated.js.
62 'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows',
63 'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
64 'gate_unknown', 'gate_threshold', 'gate_threshold_unknown',
65 'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
66 // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
67 'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
68 'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days',
69 'help_archive', 'help_archive_hide', 'panel_history',
70 'gate_propose_open', 'gate_propose_close', 'gate_default_off'];
71 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
72 s.wave = i18nT(L, 'guardian.wave');
73 s.waved = i18nT(L, 'guardian.waved');
74 return s;
75}
76
77function dashboardState(site, L) {
78 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
79 const me = AP.actorId(base, site.slug);
80 const help = db.prepare(
81 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
82 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
83 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
84 ).all(site.slug);
85 // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is
86 // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies
87 // het scherm dat een guardian in een haast openslaat.
88 const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri));
89 const helpItems = help.map((h) => ({
90 ...h,
91 // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
92 // niet is, is de gevaarlijke fout -- niet andersom.
93 state: helpStaat.get(h.object_uri) || { open: true, pickedUpBy: [], handled: null, ageMs: null },
94 // The dashboard is built in the browser, so it gets the body finished: the
95 // same partial de Krant and Berichten use. A ๐Ÿ›Ÿ often carries a screenshot
96 // and a link to the post it is about; both belong in the card.
97 body_html: renderNoteBody(h, L),
98 name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
99 // In the site's own timezone, the same as everywhere else in Klonkt. The
100 // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
101 when_text: formatDateTime(h.published || h.created_at),
102 }));
103 return {
104 site: site.slug,
105 me,
106 // Committed wards, each carrying the gated settings a guardian may change.
107 // `embeds` is null for a ward we do not host: that setting lives on the
108 // ward's own server, so we show it as not-adjustable rather than lying.
109 // `guardians` (FEP-633c 3.6): the fellow guardians of a LOCAL ward with
110 // their availability; null for a remote ward, whose server tracks it.
111 wards: Guardianship.listWards(site.slug).map((w) => ({
112 ...w,
113 embeds: wardEmbedSetting(w.other_uri),
114 playback: wardPlaybackSetting(w.other_uri),
115 guardians: wardGuardianStatuses(w.other_uri),
116 // What THIS guardian proposed for this ward and how it stands (5.6):
117 // open, accepted, rejected, or expired when the window ran out and the
118 // ward's server had nothing to write home. The answer is a real
119 // Accept/Reject from the ward's server, not a guess from here.
120 proposals: Guardianship.gated.listSent(site.slug, w.other_uri).map((p) => ({
121 feature: p.feature, value: !!p.value, created: p.created_at,
122 status: Guardianship.gated.sentStatus(p, Date.now()),
123 })),
124 // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
125 // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
126 // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
127 gates: wardGates(site.slug, w.other_uri),
128 })),
129 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
130 // Running lapses (3.6.3) this guardian or its local wards are party to.
131 lapses: Guardianship.availability.lapseQueueItems(site.slug, me, Date.now()),
132 // Gated-setting proposals another guardian opened on a ward we share
133 // (5.6), forwarded here by the ward's server. Without answering these the
134 // threshold is never met and the proposal simply expires.
135 gatedReviews: Guardianship.gated.listGatedReviews(site.slug).map((r) => ({
136 id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
137 })),
138 help: helpItems,
139 strings: uiStrings(L),
140 };
141}
142
143/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
144 * spirit; the co-guardians are among the owners of the relationship). Null
145 * for a remote ward: its server tracks availability, not us. */
146function wardGuardianStatuses(wardUri) {
147 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
148 if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
149 const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
150 try {
151 const uris = Guardianship.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
152 const st = Object.fromEntries(
153 Guardianship.availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
154 );
155 return uris.map((u) => ({
156 uri: u.uri,
157 handle: u.handle,
158 availability: (st[u.uri] || {})['shaer:availability'] || 'active',
159 awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
160 lapse: (st[u.uri] || {})['shaer:lapse'] || null,
161 }));
162 } catch { return null; }
163}
164
165// โ”€โ”€ The PWA page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
166router.get('/', requireAuth, (req, res) => {
167 const site = siteForUser(req);
168 const L = resolveLang(req);
169 if (!site) return res.status(404).send('No site for this account.');
170 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
171 // This standalone PWA page is rendered directly (not through renderPage), so
172 // the CSP nonce must be injected here โ€” otherwise strict-dynamic blocks
173 // guardian.js and the whole dashboard is dead (buttons do nothing).
174 res.render('pages/guardian', {
175 state: dashboardState(site, L),
176 sites,
177 lang: L,
178 t: (k, v) => i18nT(L, k, v),
179 cspNonce: res.locals.cspNonce,
180 }, (err, html) => {
181 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
182 res.send(injectCspNonce(html, res.locals.cspNonce));
183 });
184});
185
186// โ”€โ”€ JSON state for refreshes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
187router.get('/api/state', requireAuth, (req, res) => {
188 const site = siteForUser(req);
189 if (!site) return res.status(404).json({ error: 'no_site' });
190 res.json(dashboardState(site, resolveLang(req)));
191});
192
193// โ”€โ”€ Meekijken (FEP-633c ยง5, interop-hoofdroute): a committed guardian FOLLOWS
194// its wards, so their posts (incl. followers-only) are DELIVERED to the
195// guardian's inbox โ†’ timeline. The follow is the mechanism; no new fetch.
196// First contact also backfills the ward's recent PUBLIC posts as a cold
197// start so the corner is not empty before delivery catches up.
198function ensureWardConnections(site) {
199 let wards;
200 try { wards = Guardianship.listWards(site.slug); } catch { return; }
201 for (const w of wards) {
202 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
203 .get(site.slug, w.other_uri);
204 if (already) continue;
205 // Follow (guardian's server auto-accepts today; ยง5.3 gating is a later fase).
206 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
207 // Cold start: pull recent public posts now so oma sees something at once.
208 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
209 }
210}
211
212// โ”€โ”€ The wards' corner: your wards' posts, read-only. No reply, no share; a
213// guardian watches, it does not publish (Robins besluit).
214router.get('/api/feed', requireAuth, (req, res) => {
215 const site = siteForUser(req);
216 if (!site) return res.status(404).json({ error: 'no_site' });
217 const L = resolveLang(req);
218 ensureWardConnections(site);
219 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
220 // Only show the wards you actually guard (the timeline can hold more).
221 const items = AP.getTimeline(site.slug, 60, 0)
222 .filter((p) => wardUris.has(p.author_uri))
223 .map((p) => ({
224 id: p.id,
225 author: p.author_handle || p.author_name || p.author_uri,
226 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
227 authorName: p.author_name,
228 authorIcon: p.author_icon,
229 content: p.content,
230 url: p.url,
231 published: p.published || p.created_at,
232 when_text: formatDateTime(p.published || p.created_at),
233 cw: p.cw || null,
234 media: p.media_json ? JSON.parse(p.media_json) : [],
235 // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
236 // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
237 // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
238 // een lege regel waar een foto stond. `content` blijft ernaast staan voor
239 // een client die nog uit de cache draait.
240 body_html: renderNoteBody(p, L),
241 }));
242 res.json({ items, following: wardUris.size });
243});
244
245// โ”€โ”€ Follow-gating (FEP-633c ยง5.3): pending follows on MY wards, for me to
246// approve. Ward and guardian are co-located on the family Klonkt here, so
247// the guardian reads its wards' pending follows locally.
248function wardSlugsOf(site) {
249 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
250 return Guardianship.listWards(site.slug)
251 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
252 .filter(Boolean);
253}
254
255router.get('/api/follow-requests', requireAuth, (req, res) => {
256 const site = siteForUser(req);
257 if (!site) return res.status(404).json({ error: 'no_site' });
258 const items = [];
259 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
260 // wardUri is the grouping key for the per-ward panel: the handle is for
261 // reading, the URI is what identifies the child across both cases below.
262 // Local wards (guardian co-located): read the pending follows directly.
263 for (const w of wardSlugsOf(site)) {
264 for (const f of Guardianship.follows.listForWard(w.slug)) {
265 items.push({ id: f.id, ward: `@${w.slug}@${host}`, wardUri: w.uri, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
266 }
267 }
268 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
269 for (const rev of Guardianship.follows.listReviews(site.slug)) {
270 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
271 items.push({ id: rev.id, ward: wardName, wardUri: rev.ward_uri, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
272 }
273 res.json({ items });
274});
275
276router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
277 const site = siteForUser(req);
278 if (!site) return res.status(404).json({ error: 'no_site' });
279 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
280 const me = AP.actorId(base, site.slug);
281 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
282
283 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
284 // which tallies quorum and returns the Accept(Follow) to the follower.
285 const review = Guardianship.follows.getReview(site.slug, req.params.id);
286 if (review) {
287 try { await AP.sendFollowDecision(site, review, decision); }
288 catch { return res.status(502).json({ error: 'delivery' }); }
289 Guardianship.follows.removeReview(site.slug, req.params.id);
290 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
291 }
292
293 // Local ward: decide directly (quorum on this instance).
294 const pending = Guardianship.follows.getPending(req.params.id);
295 if (!pending) return res.status(404).json({ error: 'gone' });
296 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
297 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
298 // Acting from the dashboard is an answer (3.6), and the quorum runs over
299 // the available set (3.5): both applied here, the same as over the wire.
300 Guardianship.availability.oneAnswer(me, Date.now());
301 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
302 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
303 try {
304 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
305 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
306 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
307 res.json({ ok: true, outcome: r.outcome });
308});
309
310// โ”€โ”€ ยง5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
311// and the guardians decide. Same quorum arithmetic and the same availability
312// rules as the inbound gate above; only the question is turned around, which
313// is why it gets its own endpoint rather than a flag on that one.
314router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
315 const site = siteForUser(req);
316 if (!site) return res.status(404).json({ error: 'no_site' });
317 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
318 const me = AP.actorId(base, site.slug);
319 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
320
321 const pending = Guardianship.outgoing.getPending(req.params.id);
322 if (!pending) return res.status(404).json({ error: 'gone' });
323 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
324 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
325 Guardianship.availability.oneAnswer(me, Date.now());
326 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
327 const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
328 try {
329 // Only on approval does anything leave the building. A refusal is a local
330 // fact: the follow was never sent, so there is nothing out there to undo
331 // and nobody to inform that a child asked about them.
332 if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
333 } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
334 res.json({ ok: true, outcome: r.outcome });
335});
336
337// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
338// guardian to a ward. A private direct note, never a feed post. Warmth
339// without publishing (Robins besluit).
340router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
341 const site = siteForUser(req);
342 if (!site) return res.status(404).json({ error: 'no_site' });
343 const wardUri = String(req.body?.ward || '').trim();
344 // Only wave at a ward you actually guard.
345 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
346 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
347 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
348 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
349 if (!r) return res.status(502).json({ error: 'delivery' });
350 res.json({ ok: true, delivered: r.delivered });
351});
352
353// โ”€โ”€ Een hulpvraag oppikken of afsluiten (shaer-lgo) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
354// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
355// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
356// loopt, zodat niemand denkt dat de ander het al doet.
357//
358// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
359// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
360// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
361// een ward: nooit een window.confirm.
362router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
363 const site = siteForUser(req);
364 if (!site) return res.status(404).json({ error: 'no_site' });
365 const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
366 const noteUri = String(req.body?.note || '').trim();
367 const wardUri = String(req.body?.ward || '').trim();
368 if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
369 // Alleen over een hulpvraag van een kind dat je echt bewaakt.
370 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
371 if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
372
373 const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
374 // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
375 // degene die klikt hoort niet te liegen omdat een andere server traag is.
376 Guardianship.help.record(noteUri, me, kind, null);
377
378 const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
379 const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
380 const r = await AP.deliverDirectNote(site, {
381 recipients: ontvangers,
382 text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
383 helpMark: { kind, noteUri },
384 }).catch(() => null);
385 // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
386 // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
387 // stilte.
388 res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
389});
390
391// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
392// the Shaer apps use (one path, one behavior).
393router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
394 const site = siteForUser(req);
395 if (!site) return res.status(404).json({ error: 'no_site' });
396 const handle = String(req.body?.handle || '').trim();
397 if (!handle) return res.status(400).json({ error: 'empty_handle' });
398 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
399 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
400 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
401 const me = AP.actorId(base, site.slug);
402 const r = await AP.ingestOutboxActivity(site, req.session.user, {
403 type: 'Offer',
404 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
405 });
406 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
407 // offer is recorded and delivery is retried in the background.
408 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
409 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
410});
411
412// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
413// "complete"). All three are a C2S Accept/Reject on the offer id; the
414// handshake module decides when it commits (ยง3.1).
415// โ”€โ”€ Step away (FEP-633c 3.6.1): the guardian declares itself unavailable โ”€โ”€
416// One direct note with shaer:away and an endTime to every ward, the same path
417// Shaer takes over C2S, and the only path: a ward on this instance receives
418// that note through the loopback and applies the absence in its own inbox
419// handler, exactly as a ward elsewhere does. This route used to write the
420// local wards itself as well, which meant the wire version could break without
421// anyone here noticing.
422router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
423 const site = siteForUser(req);
424 if (!site) return res.status(404).json({ error: 'no_site' });
425 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
426 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
427 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
428 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
429 const until = Date.now() + days * 24 * 3600 * 1000;
430 const L = resolveLang(req);
431 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
432 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
433 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
434 res.json({ ok: true, until });
435});
436
437// โ”€โ”€ Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
438// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
439// A local ward opens directly; a remote ward gets the proposal delivered,
440// because the ward's server is the one that tallies and enforces.
441router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
442 const site = siteForUser(req);
443 if (!site) return res.status(404).json({ error: 'no_site' });
444 const ward = String(req.body?.ward || '').trim();
445 const target = String(req.body?.target || '').trim();
446 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
447 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
448 return res.status(403).json({ error: 'not_my_ward' });
449 }
450 const r = await AP.ingestOutboxActivity(site, req.session.user, {
451 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
452 });
453 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
454 res.json({ ok: true, lapse: r.id });
455});
456
457// โ”€โ”€ Answer a forwarded gated-setting proposal (FEP-633c 5.6) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
458// The decision belongs to the ward's server, so the answer travels there as an
459// Accept/Reject on the offer id, exactly like a gated follow's decision.
460router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
461 const site = siteForUser(req);
462 if (!site) return res.status(404).json({ error: 'no_site' });
463 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
464 if (!review) return res.status(404).json({ error: 'gone' });
465 const agree = req.body?.answer !== 'reject';
466 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
467 const me = AP.actorId(base, site.slug);
468 const activity = {
469 id: `${me}#gated-${Date.now().toString(36)}`,
470 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
471 };
472 try { await AP.deliverToActor(site, review.ward_uri, activity); }
473 catch { return res.status(502).json({ error: 'delivery' }); }
474 Guardianship.gated.removeGatedReview(site.slug, review.id);
475 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
476});
477
478router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
479 const site = siteForUser(req);
480 if (!site) return res.status(404).json({ error: 'no_site' });
481 const offerId = String(req.body?.offer || '').trim();
482 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
483 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
484 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
485 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
486 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
487});
488
489// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
490// /assets cache or a stuck install (that was the whole "nothing works after
491// a deploy" bug). Small files; the browser revalidates and gets a 304 when
492// unchanged, the fresh file when changed.
493function pwaAsset(rel, type) {
494 return (req, res) => {
495 res.set('Cache-Control', 'no-cache');
496 res.type(type);
497 res.sendFile(path.join(__dir, '..', 'assets', rel));
498 };
499}
500router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
501router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
502
503// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
504/**
505 * What actually happens if this guardian releases this ward?
506 *
507 * Releasing is not one action but two very different ones, and the difference
508 * is the number of guardians the child has left (FEP-633c):
509 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
510 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
511 * that no single guardian decides it alone (three consenting adults, or a
512 * majority plus two witnesses).
513 * On top of that, today's release is LOCAL: the Undo is not federated yet
514 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
515 * A guardian pressing the button would otherwise believe the child is released.
516 *
517 * Answered on demand rather than in the dashboard state: for a ward we do not
518 * host this reaches out to that ward's server, and nobody should pay for that
519 * on every refresh.
520 */
521router.get('/wards/release-check', requireAuth, async (req, res) => {
522 const site = siteForUser(req);
523 if (!site) return res.status(404).json({ error: 'no_site' });
524 const uri = String(req.query.uri || '').trim();
525 if (!uri) return res.status(400).json({ error: 'empty_uri' });
526 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
527 return res.status(403).json({ error: 'not_my_ward' });
528 }
529 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
530 const local = !!base && uri.startsWith(`${base}/`);
531 let guardians = null; // null = we could not find out; say so rather than guess
532 if (local) {
533 const slug = uri.replace(/\/+$/, '').split('/').pop();
534 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
535 } else {
536 const doc = await AP.fetchActor(uri).catch(() => null);
537 const g = doc && doc['shaer:guardians'];
538 if (Array.isArray(g)) guardians = g.length;
539 else if (typeof g === 'string') guardians = 1;
540 else if (g && Array.isArray(g.items)) guardians = g.items.length;
541 else if (doc) guardians = 0; // the actor answered and names no guardians
542 }
543 res.json({
544 guardians,
545 last: guardians === null ? null : guardians <= 1,
546 local,
547 });
548});
549
550// โ”€โ”€ The fellow guardians of a ward, wherever it lives โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
551// A guardian looking at a ward's panel should see who else holds a seat: that
552// is the child's safety net, and "dit kind woont op een andere server" is not
553// an answer. For a local ward the availability rides along (we do that
554// bookkeeping). For a remote ward we read the PUBLIC membership from its
555// actor document (shaer:guardians, ยง2.1) and nothing more: availability is
556// the ward's server's private ledger (ยง3.6.1) and stays there. Fetched on
557// panel-open rather than into the dashboard, so one slow remote server does
558// not hold the whole screen hostage.
559router.get('/wards/guardians', requireAuth, async (req, res) => {
560 const site = siteForUser(req);
561 if (!site) return res.status(404).json({ error: 'no_site' });
562 const uri = String(req.query.uri || '').trim();
563 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
564 return res.status(403).json({ error: 'not_my_ward' });
565 }
566 const local = wardGuardianStatuses(uri);
567 if (local) return res.json({ local: true, guardians: local });
568 const doc = await AP.fetchActor(uri).catch(() => null);
569 let g = doc && doc['shaer:guardians'];
570 if (g && Array.isArray(g.items)) g = g.items; // a Collection
571 const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
572 .filter((x) => typeof x === 'string')
573 .map((u) => {
574 try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
575 catch { return { uri: u, handle: u }; }
576 });
577 res.json({ local: false, guardians });
578});
579
580router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
581 const site = siteForUser(req);
582 if (!site) return res.status(404).json({ error: 'no_site' });
583 const uri = String(req.body?.uri || '').trim();
584 if (!uri) return res.status(400).json({ error: 'empty_uri' });
585 // Ending a guardianship is an Undo of the Relationship that travels to the
586 // ward and the other guardians (ยง3.2), not a local delete. Same call the
587 // Guardian apps reach over C2S, so the two cannot drift apart.
588 const r = await Guardianship.endGuardianship(site, uri);
589 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
590 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
591});
592
593/**
594 * The external-embeds setting of a ward we host: true/false when a guardian has
595 * decided, null when it is still on auto (which means off for a ward) or when
596 * the ward lives elsewhere and the setting is not ours to show.
597 */
598function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
599/** The playback gate of a ward we host (5.6): the heavier sibling. */
600function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
601/**
602 * De gate-rijen van een ward voor het paneel.
603 *
604 * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
605 * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
606 * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
607 * getal wordt er geen drempel verzonnen.
608 */
609function wardGates(mySlug, wardUri) {
610 const statuses = wardGuardianStatuses(wardUri);
611 const wachtend = Guardianship.follows.listReviewsByDirection(mySlug, 'incoming')
612 .filter((r) => r.ward_uri === wardUri).length;
613 return Guardianship.gated.gateRows({
614 // Uit de BESLUITEN, niet uit onze eigen kolom. Er zijn geen lokale accounts:
615 // elke ward woont elders, dus wardEmbedSetting() gaf voor iedere ward null en
616 // stond er in het paneel overal "onbekend". Wat een guardian wel heeft is de
617 // uitslag van wat hij voorstelde.
618 settings: {
619 'shaer:externalEmbeds': Guardianship.gated.knownSetting(mySlug, wardUri, 'shaer:externalEmbeds'),
620 'shaer:externalPlayback': Guardianship.gated.knownSetting(mySlug, wardUri, 'shaer:externalPlayback'),
621 },
622 guardianCount: statuses ? statuses.length : null,
623 proposals: Guardianship.gated.listSent(mySlug, wardUri).map((p) => ({
624 feature: p.feature, value: !!p.value, status: Guardianship.gated.sentStatus(p, Date.now()),
625 })),
626 waiting: { 'shaer:follows': wachtend || undefined },
627 });
628}
629
630function wardGateSetting(uri, column) {
631 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
632 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
633 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
634 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
635 if (!row) return null;
636 return row.v === null || row.v === undefined ? false : row.v === 1;
637}
638
639// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
640// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
641// server-side when the feed is serialised, so this endpoint is the only way it
642// can move, and only a committed guardian of THAT ward may move it.
643router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
644 req.body = { ...req.body, feature: req.body?.feature === 'shaer:externalPlayback' ? 'shaer:externalPlayback' : 'shaer:externalEmbeds' };
645 return proposeGated(req, res);
646});
647function proposeGated(req, res) {
648 const site = siteForUser(req);
649 if (!site) return res.status(404).json({ error: 'no_site' });
650 const uri = String(req.body?.uri || '').trim();
651 const allow = req.body?.allow === true;
652 if (!uri) return res.status(400).json({ error: 'empty_uri' });
653 // Only a guardian of this ward, and only for a ward we host: a setting on a
654 // remote ward belongs to that ward's own server (federating it is Fase 4).
655 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
656 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
657 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
658 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
659 // just the case where that server happens to be this one, so it takes the
660 // same road: propose, then let the tally decide. Anything else would make a
661 // guardian on the ward's own instance more powerful than one elsewhere.
662 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
663 const me = AP.actorId(base, site.slug);
664 const feature = req.body.feature; // normalised by the route above
665 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
666 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
667 // ONE path, whether the ward lives here or on the other side of the world
668 // (Robins regel, 29-7): propose over the wire and let the ward's server do
669 // what it does for everyone. deliverToActor loops a local recipient back
670 // into the same inbox handler, so co-location changes the transport and
671 // nothing else. The old shortcut recorded the vote here directly, which is
672 // how the remote path stayed broken for a month without anyone noticing.
673 // Our own record of what we sent (5.6): the ward's server answers this Offer
674 // once the decision settles, and that answer needs a row to land in. It is
675 // also the only way the proposer's screen can say more than a button caption.
676 Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
677 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
678 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
679 const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
680 res.json({ ok: true, allow, state: 'open', ...(progress || { federated: true }) });
681}
682
683// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
684// its own app next to the site PWA.
685router.get('/manifest.webmanifest', (req, res) => {
686 const site = res.locals.site;
687 res.set('Cache-Control', 'no-cache');
688 res.json({
689 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
690 name: 'Klonkt Guardian',
691 short_name: 'Guardian',
692 description: 'Ward management and help requests for guardians.',
693 scope: '/guardian/',
694 start_url: '/guardian?source=pwa',
695 display: 'standalone',
696 display_override: ['standalone', 'minimal-ui'],
697 orientation: 'any',
698 background_color: '#141a24',
699 theme_color: '#ff6b35',
700 lang: site?.language || 'nl',
701 icons: [
702 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
703 ],
704 });
705});
706
707// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
708router.get('/icon.svg', (req, res) => {
709 const svg = `<?xml version="1.0" encoding="UTF-8"?>
710<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
711 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
712 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
713</svg>`;
714 res.set('Content-Type', 'image/svg+xml');
715 res.set('Cache-Control', 'public, max-age=86400');
716 res.send(svg);
717});
718
719// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
720// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
721// zo'n account was de laatste multi-user-rest en zette bovendien andermans
722// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
723// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
724// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
725
726export default router;
Note: See TracBrowser for help on using the repository browser.