source: Klonkt/src/routes/guardian.js@ 08ab8ad

main
Last change on this file since 08ab8ad was 6d5ce0c, checked in by Robin <roboburr@โ€ฆ>, 6 weeks ago

Op deze machine gedraagt elke Klonkt zich alsof hij ergens anders staat

Robins regel, en de reden staat in de logs van deze week. Twee bugs kwamen uit
hetzelfde patroon: een tweede, lokale route die een kapotte externe route
verborg. De Undo bereikte een kind op dezelfde machine nooit, en het gated
voorstel was een maand stuk over de lijn terwijl de sluiproute de stem hier
direct opschreef en het dashboard er prima uitzag.

Samenlokatie is nu een kwestie van TRANSPORT, geen beslispad. deliverToActor
geeft een activiteit voor een lokale ontvanger door aan dezelfde inbox-handler
die de lijn zou bereiken, inclusief de controle of de ondertekenaar de afzender
is. Alles daarboven weet het verschil niet meer, en dus draait elke deployment
dezelfde code.

Directe berichten deden dat nog niet. Die zochten een inbox op en POSTten
erheen, dus een bericht aan een kind op deze machine ging naar onze eigen
hostnaam en terug, of nergens heen. Nu nemen ze dezelfde loopback.

En de kern van het probleem zat in de inbox zelf: "van onze eigen actor" werd
gelezen als "van wie dan ook op deze machine". Daardoor werd elk bericht tussen
twee sites op een instantie met een 202 aangenomen en daarna weggegooid: geen
vermelding, geen afwezigheid, geen hulpvraag. Buren zijn niet wij.

Daarmee konden twee met de hand geschreven sluiproutes weg: het lokaal
wegschrijven van een afwezigheid in de C2S-outbox en in de Guardian PWA. Die
bestonden alleen omdat de echte weg niet aankwam.

Changed files:
src/services/ActivityPubService.js

  • isLocalActor is nu "de eigenaar van deze inbox", niet "iemand op deze host"
  • localActor(): het actordocument van een site die wij hosten, uit onze eigen database in plaats van via een verzoek aan onszelf
  • de lokale sluiproute voor afwezigheid in de C2S-outbox is weg

src/services/guardianship/delivery.js

  • een lokale ontvanger krijgt het bericht via de loopback, de rest per inbox
  • een lokale ontvanger wordt lokaal opgezocht, dus hij valt niet stilletjes uit de ontvangerslijst als het verzoek aan onszelf mislukt

src/routes/guardian.js

  • /api/away schrijft niets meer zelf weg: het bericht doet het werk

src/services/guardianship/handshake.js

  • commentaar bijgewerkt bij de plekken die wel lokaal mogen schrijven

New file:
test/co-location.test.js

  • hetzelfde scenario twee keer, alles-lokaal en alles-extern, met de eis dat de eindtoestand gelijk is
  • een afwezigheid via de loopback en dezelfde brief van de lijn gelezen
  • de loopback weigert nog steeds een afzender die niet klopt
  • een bewaking die faalt zodra er een nieuwe lokale sluiproute in een beslispad verschijnt, met de legitieme uitzonderingen bij naam

remarks: 329 tests groen, en de server start. Twee dingen om te weten voor de
uitrol: sites op een instantie die elkaar volgen zien elkaars berichten nu wel
(dat is wat volgen betekent, maar het is zichtbaar anders), en gated follows
lopen voor een lokale guardian nog steeds via de gedeelde database. Die laatste
staat met naam en toenaam in de bewakingstest, zodat hij niet vergeten wordt.

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

  • Property mode set to 100644
File size: 32.9 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 path
312// Shaer takes over C2S, and the only path: a ward on this instance receives
313// that note through the loopback and applies the absence in its own inbox
314// handler, exactly as a ward elsewhere does. This route used to write the
315// local wards itself as well, which meant the wire version could break without
316// anyone here noticing.
317router.post('/api/away', 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 days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
321 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
322 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
323 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
324 const until = Date.now() + days * 24 * 3600 * 1000;
325 const L = resolveLang(req);
326 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
327 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
328 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
329 res.json({ ok: true, until });
330});
331
332// โ”€โ”€ Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
333// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
334// A local ward opens directly; a remote ward gets the proposal delivered,
335// because the ward's server is the one that tallies and enforces.
336router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
337 const site = siteForUser(req);
338 if (!site) return res.status(404).json({ error: 'no_site' });
339 const ward = String(req.body?.ward || '').trim();
340 const target = String(req.body?.target || '').trim();
341 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
342 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
343 return res.status(403).json({ error: 'not_my_ward' });
344 }
345 const r = await AP.ingestOutboxActivity(site, req.session.user, {
346 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
347 });
348 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
349 res.json({ ok: true, lapse: r.id });
350});
351
352// โ”€โ”€ Answer a forwarded gated-setting proposal (FEP-633c 5.6) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
353// The decision belongs to the ward's server, so the answer travels there as an
354// Accept/Reject on the offer id, exactly like a gated follow's decision.
355router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
356 const site = siteForUser(req);
357 if (!site) return res.status(404).json({ error: 'no_site' });
358 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
359 if (!review) return res.status(404).json({ error: 'gone' });
360 const agree = req.body?.answer !== 'reject';
361 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
362 const me = AP.actorId(base, site.slug);
363 const activity = {
364 id: `${me}#gated-${Date.now().toString(36)}`,
365 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
366 };
367 try { await AP.deliverToActor(site, review.ward_uri, activity); }
368 catch { return res.status(502).json({ error: 'delivery' }); }
369 Guardianship.gated.removeGatedReview(site.slug, review.id);
370 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
371});
372
373router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
374 const site = siteForUser(req);
375 if (!site) return res.status(404).json({ error: 'no_site' });
376 const offerId = String(req.body?.offer || '').trim();
377 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
378 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
379 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
380 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
381 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
382});
383
384// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
385// /assets cache or a stuck install (that was the whole "nothing works after
386// a deploy" bug). Small files; the browser revalidates and gets a 304 when
387// unchanged, the fresh file when changed.
388function pwaAsset(rel, type) {
389 return (req, res) => {
390 res.set('Cache-Control', 'no-cache');
391 res.type(type);
392 res.sendFile(path.join(__dir, '..', 'assets', rel));
393 };
394}
395router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
396router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
397
398// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
399/**
400 * What actually happens if this guardian releases this ward?
401 *
402 * Releasing is not one action but two very different ones, and the difference
403 * is the number of guardians the child has left (FEP-633c):
404 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
405 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
406 * that no single guardian decides it alone (three consenting adults, or a
407 * majority plus two witnesses).
408 * On top of that, today's release is LOCAL: the Undo is not federated yet
409 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
410 * A guardian pressing the button would otherwise believe the child is released.
411 *
412 * Answered on demand rather than in the dashboard state: for a ward we do not
413 * host this reaches out to that ward's server, and nobody should pay for that
414 * on every refresh.
415 */
416router.get('/wards/release-check', requireAuth, async (req, res) => {
417 const site = siteForUser(req);
418 if (!site) return res.status(404).json({ error: 'no_site' });
419 const uri = String(req.query.uri || '').trim();
420 if (!uri) return res.status(400).json({ error: 'empty_uri' });
421 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
422 return res.status(403).json({ error: 'not_my_ward' });
423 }
424 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
425 const local = !!base && uri.startsWith(`${base}/`);
426 let guardians = null; // null = we could not find out; say so rather than guess
427 if (local) {
428 const slug = uri.replace(/\/+$/, '').split('/').pop();
429 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
430 } else {
431 const doc = await AP.fetchActor(uri).catch(() => null);
432 const g = doc && doc['shaer:guardians'];
433 if (Array.isArray(g)) guardians = g.length;
434 else if (typeof g === 'string') guardians = 1;
435 else if (g && Array.isArray(g.items)) guardians = g.items.length;
436 else if (doc) guardians = 0; // the actor answered and names no guardians
437 }
438 res.json({
439 guardians,
440 last: guardians === null ? null : guardians <= 1,
441 local,
442 });
443});
444
445router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
446 const site = siteForUser(req);
447 if (!site) return res.status(404).json({ error: 'no_site' });
448 const uri = String(req.body?.uri || '').trim();
449 if (!uri) return res.status(400).json({ error: 'empty_uri' });
450 // Ending a guardianship is an Undo of the Relationship that travels to the
451 // ward and the other guardians (ยง3.2), not a local delete. Same call the
452 // Guardian apps reach over C2S, so the two cannot drift apart.
453 const r = await Guardianship.endGuardianship(site, uri);
454 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
455 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
456});
457
458/**
459 * The external-embeds setting of a ward we host: true/false when a guardian has
460 * decided, null when it is still on auto (which means off for a ward) or when
461 * the ward lives elsewhere and the setting is not ours to show.
462 */
463function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
464/** The playback gate of a ward we host (5.6): the heavier sibling. */
465function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
466function wardGateSetting(uri, column) {
467 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
468 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
469 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
470 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
471 if (!row) return null;
472 return row.v === null || row.v === undefined ? false : row.v === 1;
473}
474
475// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
476// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
477// server-side when the feed is serialised, so this endpoint is the only way it
478// can move, and only a committed guardian of THAT ward may move it.
479router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
480 req.body = { ...req.body, feature: req.body?.feature === 'shaer:externalPlayback' ? 'shaer:externalPlayback' : 'shaer:externalEmbeds' };
481 return proposeGated(req, res);
482});
483function proposeGated(req, res) {
484 const site = siteForUser(req);
485 if (!site) return res.status(404).json({ error: 'no_site' });
486 const uri = String(req.body?.uri || '').trim();
487 const allow = req.body?.allow === true;
488 if (!uri) return res.status(400).json({ error: 'empty_uri' });
489 // Only a guardian of this ward, and only for a ward we host: a setting on a
490 // remote ward belongs to that ward's own server (federating it is Fase 4).
491 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
492 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
493 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
494 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
495 // just the case where that server happens to be this one, so it takes the
496 // same road: propose, then let the tally decide. Anything else would make a
497 // guardian on the ward's own instance more powerful than one elsewhere.
498 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
499 const me = AP.actorId(base, site.slug);
500 const feature = req.body.feature; // normalised by the route above
501 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
502 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
503 // ONE path, whether the ward lives here or on the other side of the world
504 // (Robins regel, 29-7): propose over the wire and let the ward's server do
505 // what it does for everyone. deliverToActor loops a local recipient back
506 // into the same inbox handler, so co-location changes the transport and
507 // nothing else. The old shortcut recorded the vote here directly, which is
508 // how the remote path stayed broken for a month without anyone noticing.
509 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
510 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
511 const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
512 res.json({ ok: true, allow, state: 'open', ...(progress || { federated: true }) });
513}
514
515// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
516// its own app next to the site PWA.
517router.get('/manifest.webmanifest', (req, res) => {
518 const site = res.locals.site;
519 res.set('Cache-Control', 'no-cache');
520 res.json({
521 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
522 name: 'Klonkt Guardian',
523 short_name: 'Guardian',
524 description: 'Ward management and help requests for guardians.',
525 scope: '/guardian/',
526 start_url: '/guardian?source=pwa',
527 display: 'standalone',
528 display_override: ['standalone', 'minimal-ui'],
529 orientation: 'any',
530 background_color: '#141a24',
531 theme_color: '#ff6b35',
532 lang: site?.language || 'nl',
533 icons: [
534 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
535 ],
536 });
537});
538
539// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
540router.get('/icon.svg', (req, res) => {
541 const svg = `<?xml version="1.0" encoding="UTF-8"?>
542<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
543 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
544 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
545</svg>`;
546 res.set('Content-Type', 'image/svg+xml');
547 res.set('Cache-Control', 'public, max-age=86400');
548 res.send(svg);
549});
550
551// โ”€โ”€ Losse guardians (Guardian 2): uitnodigen en aansluiten โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
552// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
553// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
554// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
555
556router.post('/invite', requireAuth, (req, res) => {
557 const token = crypto.randomBytes(16).toString('base64url');
558 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
559 .run(token, req.session.user.id);
560 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
561 const url = `${base}/guardian/join/${token}`;
562 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
563 <h2>Invite a guardian</h2>
564 <p>Share this link. It lets one person create a guardian account here:</p>
565 <p><a href="${url}">${url}</a></p>
566 <p><a href="/guardian">Back</a></p></body>`);
567});
568
569function joinForm(token, error) {
570 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
571 <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
572 <h2>Become a guardian</h2>
573 <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
574 ${error ? `<p style="color:#b00">${error}</p>` : ''}
575 <form method="post" action="/guardian/join/${token}">
576 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
577 style="width:100%;padding:10px" autocapitalize="none"></p>
578 <p><input name="password" type="password" placeholder="password" required minlength="8"
579 style="width:100%;padding:10px"></p>
580 <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
581 </form></body>`;
582}
583
584router.get('/join/:token', (req, res) => {
585 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
586 .get(req.params.token);
587 if (!inv) return res.status(404).send('This invite is no longer valid.');
588 res.send(joinForm(req.params.token));
589});
590
591router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
592 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
593 .get(req.params.token);
594 if (!inv) return res.status(404).send('This invite is no longer valid.');
595 const name = String(req.body.name || '').trim().toLowerCase();
596 const password = String(req.body.password || '');
597 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
598 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
599 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
600 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
601 }
602 const userId = crypto.randomUUID();
603 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
604 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
605 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
606 .run(crypto.randomUUID(), name, name, userId);
607 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
608 .run(userId, req.params.token);
609 req.session.user = { id: userId, username: name, role: 'member' };
610 res.redirect('/guardian');
611});
612
613export default router;
Note: See TracBrowser for help on using the repository browser.