source: Klonkt/src/routes/guardian.js@ 3b43e4c

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

De gate-familie functioneel: acht poorten die echt schakelen (shaer-ahy.1)

Barts opdracht (8-8): "maak ze allemaal maar functioneel." Alle
setting-gates uit de catalogus hebben nu een kolom, zijn voorstelbaar en
beslisbaar via de bestaande tally, en worden ECHT afgedwongen -- bij de
aflevering (wat dicht is wordt nooit geserialiseerd, de regel die de
embeds al hadden) of bij de inname (wat de ward niet mag versturen
weigert de outbox met een eerlijke 403).

externalThreads de thread-kring (shaer-9y2): dicht is de kring van de

guardians met telling, open is alles; per verzoek en
buiten de threadcache om, want een poort die net
dichtging mag niet twee minuten open nawerken

images/music bijlagen gefilterd op mediaType, ook in de thread
quoteCards shaer:quote niet geserialiseerd
customEmoji Emoji-tags en byline-emoji niet geserialiseerd; de

:shortcode: blijft als tekst staan, dat is eerlijk

messages de berichten-poot dicht voor vreemden en vrienden,

maar NOOIT voor het guardian-kanaal, en de outbox
weigert directe berichten -- behalve de reddingsboei:
een poort die het hulpkanaal afsnijdt beschermt
niemand

compose de outbox weigert eigen posts; een antwoord valt

onder het gesprek, niet onder een eigen podium

accountMove de harde weigering van shaer-tge is een gate

geworden met dezelfde standaard: guardians kunnen
hem nu openzetten

De capabilities dragen de hele familie, zodat de app VOORAF weet wat hij
mag aanbieden -- de (+) kaart leest shaer:compose al. De voorstelroute
herschreef een onbekende feature stilletjes naar externalEmbeds; dat is
nu een 400, want een voorstel dat op de verkeerde poort landt mag een
guardian nooit overkomen. Het paneel leest de standen catalogusbreed.

TWEE blijven bewust gepland. publicProfile is niet een veld dat je
wegfiltert maar het hele publieke web-oppervlak (de ontwerpvraag van
shaer-hj0); een half slot leest als een heel slot en dat is gevaarlijker
dan geen. independence draagt gezag over een kind over en zijn vorm
hoort bij shaer-90v beslist te worden, niet hier geimproviseerd.

Zestien nieuwe/aangepaste toetsen, alle 640 groen.

Co-Authored-By: Claude Opus 5 <noreply@โ€ฆ>

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