source: Klonkt/src/routes/guardian.js@ 792af53

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

Het gate-paneel: een rij per gate, met het soort en de drempel (shaer-ahy.1)

Een guardian zag twee knoppen en moest zelf uitzoeken wat er verder voor dit kind
gold. Wat niet verstelbaar is stond nergens -- terwijl dat de helft is van het
antwoord op "wat mag dit kind".

EEN BRON VAN WAARHEID. GATE_CATALOGUE in gated.js is nu de lijst van gates die
deze Klonkt kent. Wat gated wordt is een ontwerpkeuze van de implementatie: de
FEP levert het mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet
de lijst. Een gate erbij is daarmee een regel data en geen nieuw stuk scherm.

HET SOORT STAAT ERBIJ, want ze werken niet hetzelfde:

setting een stand, aan of uit, terug te draaien
perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
handover draagt gezag OVER, onomkeerbaar zodra de ward hem gebruikt

Die derde bestaat nog niet (shaer-90v), maar de rij kan hem al dragen -- inclusief
"niet terug te draaien", zodat dat er niet later ingebouwd hoeft te worden.

Volgverzoeken staan er nu ook in, met het aantal dat wacht. Ze zijn niet te
verzetten (altijd aan voor een ward, 5.3) en juist daarom horen ze zichtbaar te
zijn.

TWEE DINGEN DIE HET PANEEL NIET VERZINT. Een onbekende stand is ONBEKEND en niet
uit: bij een ward op een andere server staat die kolom daar. En zonder bekend
aantal guardians komt er geen drempel op het scherm -- nul of een gok leest als
een feit, en dit is precies waar een guardian op afgaat voordat hij iets
voorstelt.

De trap uit shaer-ahy blijft: afspelen is pas te bewegen als linkvoorbeelden
aanstaan. Met dezelfde uitzondering als voorheen -- onbekend telt niet als dicht,
want dat kostte ooit een hele voorstelronde.

Terugval ingebouwd: serveert een oudere Klonkt de catalogus nog niet, dan
verschijnen de twee knoppen zoals ze waren in plaats van een leeg vak.

9 tests op gateRows, dat pure stuk waar de regels in zitten. nl/en/de. Suite
565/565. Niet gedekt: de weergave zelf, dat is client-JS.

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