source: Klonkt/src/routes/guardian.js@ 99a7b40

main
Last change on this file since 99a7b40 was 99a7b40, checked in by Claude (agent) <aiclaude@โ€ฆ>, 5 weeks ago

Guardian-PWA: ward-posts renderen als in de Krant

De PWA had het al half: een hulpvraag ging door renderNoteBody, de gedeelde
note-body-partial, en kwam dus compleet binnen. De feed met posts van je wards
niet -- die kreeg alleen kale content en een losse media-array, dus een
guardian zag een lege regel waar een foto stond, geen quote-kaart, geen embed en
geen custom emoji. Precies de post die je als guardian wilt kunnen beoordelen.

/api/feed geeft nu body_html mee, dezelfde partial als de Krant en Berichten.
content blijft ernaast staan zodat een client die nog uit de cache draait niet
op een leeg blok uitkomt.

De content warning blijft nadrukkelijk van de PWA zelf. note-body versluiert
alleen bij nsfw, terwijl de PWA elke cw dichtklapt -- strenger, en dat hoort hier
ook: dit is de app waarmee iemand meekijkt met een kind. body_html gaat dus
BINNEN de bestaande details, niet in de plaats ervan.

Overige oppervlakken nagelopen: de publieke post-thread en het C2S-pad naar de
Shaer-apps vertaalden attachments al, dus daar was niets te doen.

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

  • Property mode set to 100644
File size: 34.5 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 const L = resolveLang(req);
195 ensureWardConnections(site);
196 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
197 // Only show the wards you actually guard (the timeline can hold more).
198 const items = AP.getTimeline(site.slug, 60, 0)
199 .filter((p) => wardUris.has(p.author_uri))
200 .map((p) => ({
201 id: p.id,
202 author: p.author_handle || p.author_name || p.author_uri,
203 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
204 authorName: p.author_name,
205 authorIcon: p.author_icon,
206 content: p.content,
207 url: p.url,
208 published: p.published || p.created_at,
209 when_text: formatDateTime(p.published || p.created_at),
210 cw: p.cw || null,
211 media: p.media_json ? JSON.parse(p.media_json) : [],
212 // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
213 // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
214 // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
215 // een lege regel waar een foto stond. `content` blijft ernaast staan voor
216 // een client die nog uit de cache draait.
217 body_html: renderNoteBody(p, L),
218 }));
219 res.json({ items, following: wardUris.size });
220});
221
222// โ”€โ”€ Follow-gating (FEP-633c ยง5.3): pending follows on MY wards, for me to
223// approve. Ward and guardian are co-located on the family Klonkt here, so
224// the guardian reads its wards' pending follows locally.
225function wardSlugsOf(site) {
226 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
227 return Guardianship.listWards(site.slug)
228 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
229 .filter(Boolean);
230}
231
232router.get('/api/follow-requests', requireAuth, (req, res) => {
233 const site = siteForUser(req);
234 if (!site) return res.status(404).json({ error: 'no_site' });
235 const items = [];
236 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
237 // wardUri is the grouping key for the per-ward panel: the handle is for
238 // reading, the URI is what identifies the child across both cases below.
239 // Local wards (guardian co-located): read the pending follows directly.
240 for (const w of wardSlugsOf(site)) {
241 for (const f of Guardianship.follows.listForWard(w.slug)) {
242 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 });
243 }
244 }
245 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
246 for (const rev of Guardianship.follows.listReviews(site.slug)) {
247 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
248 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 });
249 }
250 res.json({ items });
251});
252
253router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
254 const site = siteForUser(req);
255 if (!site) return res.status(404).json({ error: 'no_site' });
256 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
257 const me = AP.actorId(base, site.slug);
258 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
259
260 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
261 // which tallies quorum and returns the Accept(Follow) to the follower.
262 const review = Guardianship.follows.getReview(site.slug, req.params.id);
263 if (review) {
264 try { await AP.sendFollowDecision(site, review, decision); }
265 catch { return res.status(502).json({ error: 'delivery' }); }
266 Guardianship.follows.removeReview(site.slug, req.params.id);
267 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
268 }
269
270 // Local ward: decide directly (quorum on this instance).
271 const pending = Guardianship.follows.getPending(req.params.id);
272 if (!pending) return res.status(404).json({ error: 'gone' });
273 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
274 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
275 // Acting from the dashboard is an answer (3.6), and the quorum runs over
276 // the available set (3.5): both applied here, the same as over the wire.
277 Guardianship.availability.oneAnswer(me, Date.now());
278 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
279 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
280 try {
281 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
282 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
283 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
284 res.json({ ok: true, outcome: r.outcome });
285});
286
287// โ”€โ”€ ยง5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
288// and the guardians decide. Same quorum arithmetic and the same availability
289// rules as the inbound gate above; only the question is turned around, which
290// is why it gets its own endpoint rather than a flag on that one.
291router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
292 const site = siteForUser(req);
293 if (!site) return res.status(404).json({ error: 'no_site' });
294 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
295 const me = AP.actorId(base, site.slug);
296 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
297
298 const pending = Guardianship.outgoing.getPending(req.params.id);
299 if (!pending) return res.status(404).json({ error: 'gone' });
300 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
301 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
302 Guardianship.availability.oneAnswer(me, Date.now());
303 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
304 const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
305 try {
306 // Only on approval does anything leave the building. A refusal is a local
307 // fact: the follow was never sent, so there is nothing out there to undo
308 // and nobody to inform that a child asked about them.
309 if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
310 } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
311 res.json({ ok: true, outcome: r.outcome });
312});
313
314// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
315// guardian to a ward. A private direct note, never a feed post. Warmth
316// without publishing (Robins besluit).
317router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
318 const site = siteForUser(req);
319 if (!site) return res.status(404).json({ error: 'no_site' });
320 const wardUri = String(req.body?.ward || '').trim();
321 // Only wave at a ward you actually guard.
322 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
323 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
324 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
325 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
326 if (!r) return res.status(502).json({ error: 'delivery' });
327 res.json({ ok: true, delivered: r.delivered });
328});
329
330// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
331// the Shaer apps use (one path, one behavior).
332router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
333 const site = siteForUser(req);
334 if (!site) return res.status(404).json({ error: 'no_site' });
335 const handle = String(req.body?.handle || '').trim();
336 if (!handle) return res.status(400).json({ error: 'empty_handle' });
337 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
338 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
339 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
340 const me = AP.actorId(base, site.slug);
341 const r = await AP.ingestOutboxActivity(site, req.session.user, {
342 type: 'Offer',
343 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
344 });
345 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
346 // offer is recorded and delivery is retried in the background.
347 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
348 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
349});
350
351// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
352// "complete"). All three are a C2S Accept/Reject on the offer id; the
353// handshake module decides when it commits (ยง3.1).
354// โ”€โ”€ Step away (FEP-633c 3.6.1): the guardian declares itself unavailable โ”€โ”€
355// One direct note with shaer:away and an endTime to every ward, the same path
356// Shaer takes over C2S, and the only path: a ward on this instance receives
357// that note through the loopback and applies the absence in its own inbox
358// handler, exactly as a ward elsewhere does. This route used to write the
359// local wards itself as well, which meant the wire version could break without
360// anyone here noticing.
361router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
362 const site = siteForUser(req);
363 if (!site) return res.status(404).json({ error: 'no_site' });
364 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
365 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
366 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
367 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
368 const until = Date.now() + days * 24 * 3600 * 1000;
369 const L = resolveLang(req);
370 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
371 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
372 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
373 res.json({ ok: true, until });
374});
375
376// โ”€โ”€ Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
377// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
378// A local ward opens directly; a remote ward gets the proposal delivered,
379// because the ward's server is the one that tallies and enforces.
380router.post('/api/lapse', 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 ward = String(req.body?.ward || '').trim();
384 const target = String(req.body?.target || '').trim();
385 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
386 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
387 return res.status(403).json({ error: 'not_my_ward' });
388 }
389 const r = await AP.ingestOutboxActivity(site, req.session.user, {
390 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
391 });
392 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
393 res.json({ ok: true, lapse: r.id });
394});
395
396// โ”€โ”€ Answer a forwarded gated-setting proposal (FEP-633c 5.6) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
397// The decision belongs to the ward's server, so the answer travels there as an
398// Accept/Reject on the offer id, exactly like a gated follow's decision.
399router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
400 const site = siteForUser(req);
401 if (!site) return res.status(404).json({ error: 'no_site' });
402 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
403 if (!review) return res.status(404).json({ error: 'gone' });
404 const agree = req.body?.answer !== 'reject';
405 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
406 const me = AP.actorId(base, site.slug);
407 const activity = {
408 id: `${me}#gated-${Date.now().toString(36)}`,
409 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
410 };
411 try { await AP.deliverToActor(site, review.ward_uri, activity); }
412 catch { return res.status(502).json({ error: 'delivery' }); }
413 Guardianship.gated.removeGatedReview(site.slug, review.id);
414 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
415});
416
417router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
418 const site = siteForUser(req);
419 if (!site) return res.status(404).json({ error: 'no_site' });
420 const offerId = String(req.body?.offer || '').trim();
421 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
422 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
423 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
424 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
425 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
426});
427
428// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
429// /assets cache or a stuck install (that was the whole "nothing works after
430// a deploy" bug). Small files; the browser revalidates and gets a 304 when
431// unchanged, the fresh file when changed.
432function pwaAsset(rel, type) {
433 return (req, res) => {
434 res.set('Cache-Control', 'no-cache');
435 res.type(type);
436 res.sendFile(path.join(__dir, '..', 'assets', rel));
437 };
438}
439router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
440router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
441
442// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
443/**
444 * What actually happens if this guardian releases this ward?
445 *
446 * Releasing is not one action but two very different ones, and the difference
447 * is the number of guardians the child has left (FEP-633c):
448 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
449 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
450 * that no single guardian decides it alone (three consenting adults, or a
451 * majority plus two witnesses).
452 * On top of that, today's release is LOCAL: the Undo is not federated yet
453 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
454 * A guardian pressing the button would otherwise believe the child is released.
455 *
456 * Answered on demand rather than in the dashboard state: for a ward we do not
457 * host this reaches out to that ward's server, and nobody should pay for that
458 * on every refresh.
459 */
460router.get('/wards/release-check', requireAuth, async (req, res) => {
461 const site = siteForUser(req);
462 if (!site) return res.status(404).json({ error: 'no_site' });
463 const uri = String(req.query.uri || '').trim();
464 if (!uri) return res.status(400).json({ error: 'empty_uri' });
465 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
466 return res.status(403).json({ error: 'not_my_ward' });
467 }
468 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
469 const local = !!base && uri.startsWith(`${base}/`);
470 let guardians = null; // null = we could not find out; say so rather than guess
471 if (local) {
472 const slug = uri.replace(/\/+$/, '').split('/').pop();
473 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
474 } else {
475 const doc = await AP.fetchActor(uri).catch(() => null);
476 const g = doc && doc['shaer:guardians'];
477 if (Array.isArray(g)) guardians = g.length;
478 else if (typeof g === 'string') guardians = 1;
479 else if (g && Array.isArray(g.items)) guardians = g.items.length;
480 else if (doc) guardians = 0; // the actor answered and names no guardians
481 }
482 res.json({
483 guardians,
484 last: guardians === null ? null : guardians <= 1,
485 local,
486 });
487});
488
489// โ”€โ”€ The fellow guardians of a ward, wherever it lives โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
490// A guardian looking at a ward's panel should see who else holds a seat: that
491// is the child's safety net, and "dit kind woont op een andere server" is not
492// an answer. For a local ward the availability rides along (we do that
493// bookkeeping). For a remote ward we read the PUBLIC membership from its
494// actor document (shaer:guardians, ยง2.1) and nothing more: availability is
495// the ward's server's private ledger (ยง3.6.1) and stays there. Fetched on
496// panel-open rather than into the dashboard, so one slow remote server does
497// not hold the whole screen hostage.
498router.get('/wards/guardians', requireAuth, async (req, res) => {
499 const site = siteForUser(req);
500 if (!site) return res.status(404).json({ error: 'no_site' });
501 const uri = String(req.query.uri || '').trim();
502 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
503 return res.status(403).json({ error: 'not_my_ward' });
504 }
505 const local = wardGuardianStatuses(uri);
506 if (local) return res.json({ local: true, guardians: local });
507 const doc = await AP.fetchActor(uri).catch(() => null);
508 let g = doc && doc['shaer:guardians'];
509 if (g && Array.isArray(g.items)) g = g.items; // a Collection
510 const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
511 .filter((x) => typeof x === 'string')
512 .map((u) => {
513 try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
514 catch { return { uri: u, handle: u }; }
515 });
516 res.json({ local: false, guardians });
517});
518
519router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
520 const site = siteForUser(req);
521 if (!site) return res.status(404).json({ error: 'no_site' });
522 const uri = String(req.body?.uri || '').trim();
523 if (!uri) return res.status(400).json({ error: 'empty_uri' });
524 // Ending a guardianship is an Undo of the Relationship that travels to the
525 // ward and the other guardians (ยง3.2), not a local delete. Same call the
526 // Guardian apps reach over C2S, so the two cannot drift apart.
527 const r = await Guardianship.endGuardianship(site, uri);
528 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
529 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
530});
531
532/**
533 * The external-embeds setting of a ward we host: true/false when a guardian has
534 * decided, null when it is still on auto (which means off for a ward) or when
535 * the ward lives elsewhere and the setting is not ours to show.
536 */
537function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
538/** The playback gate of a ward we host (5.6): the heavier sibling. */
539function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
540function wardGateSetting(uri, column) {
541 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
542 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
543 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
544 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
545 if (!row) return null;
546 return row.v === null || row.v === undefined ? false : row.v === 1;
547}
548
549// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
550// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
551// server-side when the feed is serialised, so this endpoint is the only way it
552// can move, and only a committed guardian of THAT ward may move it.
553router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
554 req.body = { ...req.body, feature: req.body?.feature === 'shaer:externalPlayback' ? 'shaer:externalPlayback' : 'shaer:externalEmbeds' };
555 return proposeGated(req, res);
556});
557function proposeGated(req, res) {
558 const site = siteForUser(req);
559 if (!site) return res.status(404).json({ error: 'no_site' });
560 const uri = String(req.body?.uri || '').trim();
561 const allow = req.body?.allow === true;
562 if (!uri) return res.status(400).json({ error: 'empty_uri' });
563 // Only a guardian of this ward, and only for a ward we host: a setting on a
564 // remote ward belongs to that ward's own server (federating it is Fase 4).
565 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
566 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
567 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
568 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
569 // just the case where that server happens to be this one, so it takes the
570 // same road: propose, then let the tally decide. Anything else would make a
571 // guardian on the ward's own instance more powerful than one elsewhere.
572 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
573 const me = AP.actorId(base, site.slug);
574 const feature = req.body.feature; // normalised by the route above
575 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
576 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
577 // ONE path, whether the ward lives here or on the other side of the world
578 // (Robins regel, 29-7): propose over the wire and let the ward's server do
579 // what it does for everyone. deliverToActor loops a local recipient back
580 // into the same inbox handler, so co-location changes the transport and
581 // nothing else. The old shortcut recorded the vote here directly, which is
582 // how the remote path stayed broken for a month without anyone noticing.
583 // Our own record of what we sent (5.6): the ward's server answers this Offer
584 // once the decision settles, and that answer needs a row to land in. It is
585 // also the only way the proposer's screen can say more than a button caption.
586 Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
587 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
588 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
589 const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
590 res.json({ ok: true, allow, state: 'open', ...(progress || { federated: true }) });
591}
592
593// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
594// its own app next to the site PWA.
595router.get('/manifest.webmanifest', (req, res) => {
596 const site = res.locals.site;
597 res.set('Cache-Control', 'no-cache');
598 res.json({
599 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
600 name: 'Klonkt Guardian',
601 short_name: 'Guardian',
602 description: 'Ward management and help requests for guardians.',
603 scope: '/guardian/',
604 start_url: '/guardian?source=pwa',
605 display: 'standalone',
606 display_override: ['standalone', 'minimal-ui'],
607 orientation: 'any',
608 background_color: '#141a24',
609 theme_color: '#ff6b35',
610 lang: site?.language || 'nl',
611 icons: [
612 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
613 ],
614 });
615});
616
617// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
618router.get('/icon.svg', (req, res) => {
619 const svg = `<?xml version="1.0" encoding="UTF-8"?>
620<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
621 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
622 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
623</svg>`;
624 res.set('Content-Type', 'image/svg+xml');
625 res.set('Cache-Control', 'public, max-age=86400');
626 res.send(svg);
627});
628
629// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
630// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
631// zo'n account was de laatste multi-user-rest en zette bovendien andermans
632// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
633// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
634// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
635
636export default router;
Note: See TracBrowser for help on using the repository browser.