source: Klonkt/src/routes/guardian.js@ 329873e

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

Herstel: een losse ) sloopte guardian.js, en dus sound-fabrics

Bij het feature-bewust maken van de voorstel-route heb ik de arrow-functie
omgezet naar een gewone functie en de afsluiter laten staan: het lichaam eindigt
op }); in plaats van }. Dat is een syntaxfout, guardian.js laadde niet meer, en
de server startte niet meer op. sound-fabrics gaf 502.

Erger dan de fout is dat 280 tests groen stonden. Geen enkele test importeert
een route-bestand: de suite dekte de logica en miste de bedrading. Een
syntaxfout in een route was daarmee onzichtbaar tot een server niet meer
opstartte.

Daarom test/routes-load.test.js: importeer elk bestand in src/routes en laat
het vallen als het niet laadt. Geverifieerd tegen de kapotte versie, die faalt
er wel degelijk op.

Changed files:
src/routes/guardian.js

  • de losse ); aan het eind van proposeGated

New file:
test/routes-load.test.js

  • elk route-bestand moet laden; dekt parse-fouten, kapotte imports en namen die verhuisd zijn

remarks: 322 tests groen (42 nieuw, een per route-bestand).

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

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