source: Klonkt/src/routes/guardian.js@ 88d7c8f

main
Last change on this file since 88d7c8f was 88d7c8f, checked in by Robin Genis <roboburr@โ€ฆ>, 6 weeks ago

Een gated voorstel bereikte de andere guardians nooit

Op de vloot nagekeken waarom YouTube-voorbeelden bij beta uit blijven: het
voorstel van sound-fabrics staat er, met een ja van een van de drie guardians,
en het is stil verlopen. Niet omdat iemand bezwaar had, maar omdat niemand
anders het ooit gezien heeft.

Het voorstel wordt geadresseerd aan de server van het kind, want die telt en
handhaaft (5.6). Maar daarmee bereikt het alleen de voorsteller en het kind. De
twee guardians op andere servers weten van niets, kunnen dus niet antwoorden, en
een drempel van twee is onhaalbaar. Elk voorstel verloopt na een dag.

De ontbrekende schakel is het doorsturen, precies wat 5.3 al doet voor een
gated follow: de server van het kind kent de gezaghebbende guardian-lijst, dus
die stuurt het voorstel door. Elke guardian bewaart een kopie die hij kan
beantwoorden, en het antwoord reist terug naar het kind, dat telt.

Changed files:
src/config/database.js

  • tabel ap_gated_reviews, de guardian-kopie (zelfde vorm als ap_follow_reviews)

src/services/guardianship/gated.js

  • de kopie-opslag: bewaren, lezen, beantwoorden, opruimen

src/services/guardianship/handshake.js

  • ward-kant: doorsturen naar de andere guardians zodra het voorstel openstaat
  • guardian-kant: de doorgestuurde kopie bewaren om te kunnen antwoorden

src/routes/guardian.js

  • gatedReviews in de dashboardstaat; POST /guardian/api/gated/:id stuurt het antwoord naar de inbox van het kind

src/assets/js/guardian.js, src/assets/css/guardian.css

src/services/i18n.js

  • de teksten in nl, en, de

test/gated-settings.test.js

  • de hele keten: voorstellen, doorsturen naar allebei de anderen, de kopie opslaan, antwoorden, en pas bij twee van drie gaat de gate open

remarks: dit forceert niets; het maakt alleen mogelijk wat de spec al bedoelde.
Twee van de drie guardians moeten nog steeds akkoord gaan, en het venster is
24 uur.

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

  • Property mode set to 100644
File size: 32.4 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 crypto from 'crypto';
13import bcrypt from 'bcryptjs';
14import path from 'path';
15import { fileURLToPath } from 'url';
16import db from '../config/database.js';
17import { requireAuth } from '../middleware/auth.js';
18import AP from '../services/ActivityPubService.js';
19import * as Guardianship from '../services/guardianship/index.js';
20import { t as i18nT, resolveLang } from '../services/i18n.js';
21import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
22import { emojiName } from '../services/NoteRender.js';
23
24const router = express.Router();
25const __dir = path.dirname(fileURLToPath(import.meta.url));
26
27/** The acting site: ?site=slug when owned, else the user's first site. */
28function siteForUser(req) {
29 const userId = req.session.user.id;
30 const want = String(req.query.site || req.body?.site || '').trim();
31 if (want) {
32 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
33 if (s) return s;
34 }
35 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
36}
37
38/** Everything the dashboard shows, one shape for page and API. */
39function uiStrings(L) {
40 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
41 'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
42 'embeds_on', 'embeds_off', 'embeds_propose', 'embeds_waiting',
43 'accept', 'reject', 'complete', 'awaiting_others', 'coguard',
44 // The per-ward panel: everything about one child in one place.
45 'settings_title', 'panel_open', 'panel_close', 'panel_help', 'panel_help_empty',
46 'panel_follow', 'panel_follow_empty', 'panel_posts', 'panel_posts_empty',
47 'panel_actions', 'badge_help', 'badge_follow', 'badge_follow_one', 'help_empty',
48 // Releasing a ward: a deliberate two-step answer, never one click.
49 'release_title', 'release_effect', 'release_local', 'release_step_down',
50 'release_last', 'release_unknown', 'release_yes', 'release_no',
51 // Availability (FEP-633c 3.6): the dots, the step-away, the lapse.
52 'avail_available', 'avail_away', 'avail_dormant', 'panel_guards', 'panel_guards_remote',
53 'lapse_propose', 'lapse_line', 'lapse_tally', 'lapse_note', 'lapse_agree', 'lapse_disagree', 'voted',
54 'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
55 // A gated-setting proposal from a fellow guardian (5.6).
56 'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree'];
57 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
58 s.wave = i18nT(L, 'guardian.wave');
59 s.waved = i18nT(L, 'guardian.waved');
60 return s;
61}
62
63function dashboardState(site, L) {
64 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
65 const me = AP.actorId(base, site.slug);
66 const help = db.prepare(
67 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
68 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
69 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
70 ).all(site.slug).map((h) => ({
71 ...h,
72 // The dashboard is built in the browser, so it gets the body finished: the
73 // same partial de Krant and Berichten use. A ๐Ÿ›Ÿ often carries a screenshot
74 // and a link to the post it is about; both belong in the card.
75 body_html: renderNoteBody(h, L),
76 name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
77 // In the site's own timezone, the same as everywhere else in Klonkt. The
78 // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
79 when_text: formatDateTime(h.published || h.created_at),
80 }));
81 return {
82 site: site.slug,
83 me,
84 // Committed wards, each carrying the gated settings a guardian may change.
85 // `embeds` is null for a ward we do not host: that setting lives on the
86 // ward's own server, so we show it as not-adjustable rather than lying.
87 // `guardians` (FEP-633c 3.6): the fellow guardians of a LOCAL ward with
88 // their availability; null for a remote ward, whose server tracks it.
89 wards: Guardianship.listWards(site.slug).map((w) => ({
90 ...w,
91 embeds: wardEmbedSetting(w.other_uri),
92 guardians: wardGuardianStatuses(w.other_uri),
93 })),
94 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
95 // Running lapses (3.6.3) this guardian or its local wards are party to.
96 lapses: Guardianship.availability.lapseQueueItems(site.slug, me, Date.now()),
97 // Gated-setting proposals another guardian opened on a ward we share
98 // (5.6), forwarded here by the ward's server. Without answering these the
99 // threshold is never met and the proposal simply expires.
100 gatedReviews: Guardianship.gated.listGatedReviews(site.slug).map((r) => ({
101 id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
102 })),
103 help,
104 strings: uiStrings(L),
105 };
106}
107
108/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
109 * spirit; the co-guardians are among the owners of the relationship). Null
110 * for a remote ward: its server tracks availability, not us. */
111function wardGuardianStatuses(wardUri) {
112 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
113 if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
114 const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
115 try {
116 const uris = Guardianship.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
117 const st = Object.fromEntries(
118 Guardianship.availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
119 );
120 return uris.map((u) => ({
121 uri: u.uri,
122 handle: u.handle,
123 availability: (st[u.uri] || {})['shaer:availability'] || 'active',
124 awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
125 lapse: (st[u.uri] || {})['shaer:lapse'] || null,
126 }));
127 } catch { return null; }
128}
129
130// โ”€โ”€ The PWA page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
131router.get('/', requireAuth, (req, res) => {
132 const site = siteForUser(req);
133 const L = resolveLang(req);
134 if (!site) return res.status(404).send('No site for this account.');
135 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
136 // This standalone PWA page is rendered directly (not through renderPage), so
137 // the CSP nonce must be injected here โ€” otherwise strict-dynamic blocks
138 // guardian.js and the whole dashboard is dead (buttons do nothing).
139 res.render('pages/guardian', {
140 state: dashboardState(site, L),
141 sites,
142 lang: L,
143 t: (k, v) => i18nT(L, k, v),
144 cspNonce: res.locals.cspNonce,
145 }, (err, html) => {
146 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
147 res.send(injectCspNonce(html, res.locals.cspNonce));
148 });
149});
150
151// โ”€โ”€ JSON state for refreshes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
152router.get('/api/state', requireAuth, (req, res) => {
153 const site = siteForUser(req);
154 if (!site) return res.status(404).json({ error: 'no_site' });
155 res.json(dashboardState(site, resolveLang(req)));
156});
157
158// โ”€โ”€ Meekijken (FEP-633c ยง5, interop-hoofdroute): a committed guardian FOLLOWS
159// its wards, so their posts (incl. followers-only) are DELIVERED to the
160// guardian's inbox โ†’ timeline. The follow is the mechanism; no new fetch.
161// First contact also backfills the ward's recent PUBLIC posts as a cold
162// start so the corner is not empty before delivery catches up.
163function ensureWardConnections(site) {
164 let wards;
165 try { wards = Guardianship.listWards(site.slug); } catch { return; }
166 for (const w of wards) {
167 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
168 .get(site.slug, w.other_uri);
169 if (already) continue;
170 // Follow (guardian's server auto-accepts today; ยง5.3 gating is a later fase).
171 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
172 // Cold start: pull recent public posts now so oma sees something at once.
173 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
174 }
175}
176
177// โ”€โ”€ The wards' corner: your wards' posts, read-only. No reply, no share; a
178// guardian watches, it does not publish (Robins besluit).
179router.get('/api/feed', requireAuth, (req, res) => {
180 const site = siteForUser(req);
181 if (!site) return res.status(404).json({ error: 'no_site' });
182 ensureWardConnections(site);
183 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
184 // Only show the wards you actually guard (the timeline can hold more).
185 const items = AP.getTimeline(site.slug, 60, 0)
186 .filter((p) => wardUris.has(p.author_uri))
187 .map((p) => ({
188 id: p.id,
189 author: p.author_handle || p.author_name || p.author_uri,
190 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
191 authorName: p.author_name,
192 authorIcon: p.author_icon,
193 content: p.content,
194 url: p.url,
195 published: p.published || p.created_at,
196 when_text: formatDateTime(p.published || p.created_at),
197 cw: p.cw || null,
198 media: p.media_json ? JSON.parse(p.media_json) : [],
199 }));
200 res.json({ items, following: wardUris.size });
201});
202
203// โ”€โ”€ Follow-gating (FEP-633c ยง5.3): pending follows on MY wards, for me to
204// approve. Ward and guardian are co-located on the family Klonkt here, so
205// the guardian reads its wards' pending follows locally.
206function wardSlugsOf(site) {
207 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
208 return Guardianship.listWards(site.slug)
209 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
210 .filter(Boolean);
211}
212
213router.get('/api/follow-requests', requireAuth, (req, res) => {
214 const site = siteForUser(req);
215 if (!site) return res.status(404).json({ error: 'no_site' });
216 const items = [];
217 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
218 // wardUri is the grouping key for the per-ward panel: the handle is for
219 // reading, the URI is what identifies the child across both cases below.
220 // Local wards (guardian co-located): read the pending follows directly.
221 for (const w of wardSlugsOf(site)) {
222 for (const f of Guardianship.follows.listForWard(w.slug)) {
223 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 });
224 }
225 }
226 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
227 for (const rev of Guardianship.follows.listReviews(site.slug)) {
228 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
229 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 });
230 }
231 res.json({ items });
232});
233
234router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
235 const site = siteForUser(req);
236 if (!site) return res.status(404).json({ error: 'no_site' });
237 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
238 const me = AP.actorId(base, site.slug);
239 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
240
241 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
242 // which tallies quorum and returns the Accept(Follow) to the follower.
243 const review = Guardianship.follows.getReview(site.slug, req.params.id);
244 if (review) {
245 try { await AP.sendFollowDecision(site, review, decision); }
246 catch { return res.status(502).json({ error: 'delivery' }); }
247 Guardianship.follows.removeReview(site.slug, req.params.id);
248 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
249 }
250
251 // Local ward: decide directly (quorum on this instance).
252 const pending = Guardianship.follows.getPending(req.params.id);
253 if (!pending) return res.status(404).json({ error: 'gone' });
254 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
255 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
256 // Acting from the dashboard is an answer (3.6), and the quorum runs over
257 // the available set (3.5): both applied here, the same as over the wire.
258 Guardianship.availability.oneAnswer(me, Date.now());
259 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
260 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
261 try {
262 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
263 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
264 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
265 res.json({ ok: true, outcome: r.outcome });
266});
267
268// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
269// guardian to a ward. A private direct note, never a feed post. Warmth
270// without publishing (Robins besluit).
271router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
272 const site = siteForUser(req);
273 if (!site) return res.status(404).json({ error: 'no_site' });
274 const wardUri = String(req.body?.ward || '').trim();
275 // Only wave at a ward you actually guard.
276 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
277 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
278 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
279 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
280 if (!r) return res.status(502).json({ error: 'delivery' });
281 res.json({ ok: true, delivered: r.delivered });
282});
283
284// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
285// the Shaer apps use (one path, one behavior).
286router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
287 const site = siteForUser(req);
288 if (!site) return res.status(404).json({ error: 'no_site' });
289 const handle = String(req.body?.handle || '').trim();
290 if (!handle) return res.status(400).json({ error: 'empty_handle' });
291 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
292 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
293 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
294 const me = AP.actorId(base, site.slug);
295 const r = await AP.ingestOutboxActivity(site, req.session.user, {
296 type: 'Offer',
297 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
298 });
299 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
300 // offer is recorded and delivery is retried in the background.
301 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
302 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
303});
304
305// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
306// "complete"). All three are a C2S Accept/Reject on the offer id; the
307// handshake module decides when it commits (ยง3.1).
308// โ”€โ”€ Step away (FEP-633c 3.6.1): the guardian declares itself unavailable โ”€โ”€
309// One direct note with shaer:away and an endTime to every ward, the same
310// path Shaer takes over C2S. Wards on this instance are applied directly (a
311// local inbox never receives its own delivery); the rest travels S2S.
312router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
313 const site = siteForUser(req);
314 if (!site) return res.status(404).json({ error: 'no_site' });
315 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
316 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
317 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
318 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
319 const until = Date.now() + days * 24 * 3600 * 1000;
320 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
321 const me = AP.actorId(base, site.slug);
322 let applied = 0;
323 for (const uri of wards) {
324 const wslug = uri.startsWith(`${base}/`) ? uri.replace(/\/+$/, '').split('/').pop() : null;
325 if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === me)) {
326 Guardianship.availability.declareAway(wslug, me, until);
327 applied++;
328 }
329 }
330 const L = resolveLang(req);
331 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
332 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
333 if (!applied && !(r && r.id)) return res.status(502).json({ error: 'away_failed' });
334 res.json({ ok: true, until });
335});
336
337// โ”€โ”€ Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
338// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
339// A local ward opens directly; a remote ward gets the proposal delivered,
340// because the ward's server is the one that tallies and enforces.
341router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
342 const site = siteForUser(req);
343 if (!site) return res.status(404).json({ error: 'no_site' });
344 const ward = String(req.body?.ward || '').trim();
345 const target = String(req.body?.target || '').trim();
346 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
347 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
348 return res.status(403).json({ error: 'not_my_ward' });
349 }
350 const r = await AP.ingestOutboxActivity(site, req.session.user, {
351 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
352 });
353 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
354 res.json({ ok: true, lapse: r.id });
355});
356
357// โ”€โ”€ Answer a forwarded gated-setting proposal (FEP-633c 5.6) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
358// The decision belongs to the ward's server, so the answer travels there as an
359// Accept/Reject on the offer id, exactly like a gated follow's decision.
360router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
361 const site = siteForUser(req);
362 if (!site) return res.status(404).json({ error: 'no_site' });
363 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
364 if (!review) return res.status(404).json({ error: 'gone' });
365 const agree = req.body?.answer !== 'reject';
366 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
367 const me = AP.actorId(base, site.slug);
368 const activity = {
369 id: `${me}#gated-${Date.now().toString(36)}`,
370 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
371 };
372 try { await AP.deliverToActor(site, review.ward_uri, activity); }
373 catch { return res.status(502).json({ error: 'delivery' }); }
374 Guardianship.gated.removeGatedReview(site.slug, review.id);
375 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
376});
377
378router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
379 const site = siteForUser(req);
380 if (!site) return res.status(404).json({ error: 'no_site' });
381 const offerId = String(req.body?.offer || '').trim();
382 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
383 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
384 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
385 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
386 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
387});
388
389// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
390// /assets cache or a stuck install (that was the whole "nothing works after
391// a deploy" bug). Small files; the browser revalidates and gets a 304 when
392// unchanged, the fresh file when changed.
393function pwaAsset(rel, type) {
394 return (req, res) => {
395 res.set('Cache-Control', 'no-cache');
396 res.type(type);
397 res.sendFile(path.join(__dir, '..', 'assets', rel));
398 };
399}
400router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
401router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
402
403// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
404/**
405 * What actually happens if this guardian releases this ward?
406 *
407 * Releasing is not one action but two very different ones, and the difference
408 * is the number of guardians the child has left (FEP-633c):
409 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
410 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
411 * that no single guardian decides it alone (three consenting adults, or a
412 * majority plus two witnesses).
413 * On top of that, today's release is LOCAL: the Undo is not federated yet
414 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
415 * A guardian pressing the button would otherwise believe the child is released.
416 *
417 * Answered on demand rather than in the dashboard state: for a ward we do not
418 * host this reaches out to that ward's server, and nobody should pay for that
419 * on every refresh.
420 */
421router.get('/wards/release-check', requireAuth, async (req, res) => {
422 const site = siteForUser(req);
423 if (!site) return res.status(404).json({ error: 'no_site' });
424 const uri = String(req.query.uri || '').trim();
425 if (!uri) return res.status(400).json({ error: 'empty_uri' });
426 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
427 return res.status(403).json({ error: 'not_my_ward' });
428 }
429 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
430 const local = !!base && uri.startsWith(`${base}/`);
431 let guardians = null; // null = we could not find out; say so rather than guess
432 if (local) {
433 const slug = uri.replace(/\/+$/, '').split('/').pop();
434 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
435 } else {
436 const doc = await AP.fetchActor(uri).catch(() => null);
437 const g = doc && doc['shaer:guardians'];
438 if (Array.isArray(g)) guardians = g.length;
439 else if (typeof g === 'string') guardians = 1;
440 else if (g && Array.isArray(g.items)) guardians = g.items.length;
441 else if (doc) guardians = 0; // the actor answered and names no guardians
442 }
443 res.json({
444 guardians,
445 last: guardians === null ? null : guardians <= 1,
446 local,
447 });
448});
449
450router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
451 const site = siteForUser(req);
452 if (!site) return res.status(404).json({ error: 'no_site' });
453 const uri = String(req.body?.uri || '').trim();
454 if (!uri) return res.status(400).json({ error: 'empty_uri' });
455 // Ending a guardianship is an Undo of the Relationship that travels to the
456 // ward and the other guardians (ยง3.2), not a local delete. Same call the
457 // Guardian apps reach over C2S, so the two cannot drift apart.
458 const r = await Guardianship.endGuardianship(site, uri);
459 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
460 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
461});
462
463/**
464 * The external-embeds setting of a ward we host: true/false when a guardian has
465 * decided, null when it is still on auto (which means off for a ward) or when
466 * the ward lives elsewhere and the setting is not ours to show.
467 */
468function wardEmbedSetting(uri) {
469 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
470 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
471 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
472 const row = slug ? db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(slug) : null;
473 if (!row) return null;
474 return row.external_embeds === null || row.external_embeds === undefined ? false : row.external_embeds === 1;
475}
476
477// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
478// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
479// server-side when the feed is serialised, so this endpoint is the only way it
480// can move, and only a committed guardian of THAT ward may move it.
481router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
482 const site = siteForUser(req);
483 if (!site) return res.status(404).json({ error: 'no_site' });
484 const uri = String(req.body?.uri || '').trim();
485 const allow = req.body?.allow === true;
486 if (!uri) return res.status(400).json({ error: 'empty_uri' });
487 // Only a guardian of this ward, and only for a ward we host: a setting on a
488 // remote ward belongs to that ward's own server (federating it is Fase 4).
489 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
490 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
491 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
492 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
493 // just the case where that server happens to be this one, so it takes the
494 // same road: propose, then let the tally decide. Anything else would make a
495 // guardian on the ward's own instance more powerful than one elsewhere.
496 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
497 const me = AP.actorId(base, site.slug);
498 const feature = 'shaer:externalEmbeds';
499 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
500 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
501 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
502 const localWard = localSlug ? db.prepare('SELECT slug FROM sites WHERE slug = ?').get(localSlug) : null;
503 if (localWard) {
504 Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow);
505 const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow);
506 return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of });
507 }
508 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
509 res.json({ ok: true, allow, state: 'open', federated: true });
510});
511
512// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
513// its own app next to the site PWA.
514router.get('/manifest.webmanifest', (req, res) => {
515 const site = res.locals.site;
516 res.set('Cache-Control', 'no-cache');
517 res.json({
518 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
519 name: 'Klonkt Guardian',
520 short_name: 'Guardian',
521 description: 'Ward management and help requests for guardians.',
522 scope: '/guardian/',
523 start_url: '/guardian?source=pwa',
524 display: 'standalone',
525 display_override: ['standalone', 'minimal-ui'],
526 orientation: 'any',
527 background_color: '#141a24',
528 theme_color: '#ff6b35',
529 lang: site?.language || 'nl',
530 icons: [
531 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
532 ],
533 });
534});
535
536// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
537router.get('/icon.svg', (req, res) => {
538 const svg = `<?xml version="1.0" encoding="UTF-8"?>
539<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
540 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
541 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
542</svg>`;
543 res.set('Content-Type', 'image/svg+xml');
544 res.set('Cache-Control', 'public, max-age=86400');
545 res.send(svg);
546});
547
548// โ”€โ”€ Losse guardians (Guardian 2): uitnodigen en aansluiten โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
549// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
550// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
551// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
552
553router.post('/invite', requireAuth, (req, res) => {
554 const token = crypto.randomBytes(16).toString('base64url');
555 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
556 .run(token, req.session.user.id);
557 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
558 const url = `${base}/guardian/join/${token}`;
559 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
560 <h2>Invite a guardian</h2>
561 <p>Share this link. It lets one person create a guardian account here:</p>
562 <p><a href="${url}">${url}</a></p>
563 <p><a href="/guardian">Back</a></p></body>`);
564});
565
566function joinForm(token, error) {
567 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
568 <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
569 <h2>Become a guardian</h2>
570 <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
571 ${error ? `<p style="color:#b00">${error}</p>` : ''}
572 <form method="post" action="/guardian/join/${token}">
573 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
574 style="width:100%;padding:10px" autocapitalize="none"></p>
575 <p><input name="password" type="password" placeholder="password" required minlength="8"
576 style="width:100%;padding:10px"></p>
577 <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
578 </form></body>`;
579}
580
581router.get('/join/:token', (req, res) => {
582 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
583 .get(req.params.token);
584 if (!inv) return res.status(404).send('This invite is no longer valid.');
585 res.send(joinForm(req.params.token));
586});
587
588router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
589 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
590 .get(req.params.token);
591 if (!inv) return res.status(404).send('This invite is no longer valid.');
592 const name = String(req.body.name || '').trim().toLowerCase();
593 const password = String(req.body.password || '');
594 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
595 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
596 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
597 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
598 }
599 const userId = crypto.randomUUID();
600 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
601 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
602 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
603 .run(crypto.randomUUID(), name, name, userId);
604 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
605 .run(userId, req.params.token);
606 req.session.user = { id: userId, username: name, role: 'member' };
607 res.redirect('/guardian');
608});
609
610export default router;
Note: See TracBrowser for help on using the repository browser.