source: Klonkt/src/routes/guardian.js@ fa33214

main
Last change on this file since fa33214 was fa33214, checked in by Bart <bart@…>, 5 weeks ago

FEP-633c §5.3 andersom: een ward vraagt eerst of het iemand mag volgen

Uitgaande follows gingen ongehinderd de deur uit; de guardians kregen achteraf
een bericht (1a2f206). Dat is informeren, niet gaten — de deur staat al open als
het bericht aankomt. Bead shaer-p729, ontwerp in
docs/ward-outbound-follows-design.md.

De regel: per geval goedkeuring, met twee uitzonderingen die geen gunst zijn
maar dezelfde beslissing die al genomen is. Je eigen guardian volgen is geen
vraag. En iemand die de ward al volgt DOOR DE POORT heen is door een guardian
bij naam goedgekeurd; die vraag nog eens stellen leert mensen alleen om de vraag
niet meer te lezen.

Daarvoor moet je weten wie er door de poort kwam, dus ap_followers krijgt
gate_approved, gezet bij acceptGatedFollow. Iedereen die al volgde toen die
kolom erbij kwam wordt eenmalig gegrandfatherd (Barts besluit): exact vanaf nu,
in plaats van met terugwerkende kracht wantrouwig tegen wat er al was.

Eigen tabel, want ap_pending_follows is gesleuteld met de ward als DOEL. Eigen
wachtrij (outgoingFollows), want een guardian moet "iemand wil je ward volgen"
kunnen onderscheiden van "je ward wil iemand volgen" — de AS2-test ving netjes
dat de nieuwe term aangemeld moest worden. En een tegengehouden follow reist als
derde uitkomst naar de app (state: awaiting_guardian), zodat Shaer "wacht op
toestemming" kan tonen in plaats van een tegel die er al volgend uitziet.

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

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