source: Klonkt/src/routes/guardian.js@ 6c152a5

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

De Undo bij het loslaten van een ward reist nu echt mee

Loslaten was een lokale delete. De guardian vergat het kind, terwijl de server
van het kind hem gewoon in shaer:guardians bleef noemen. De vorige commit zette
dat als waarschuwing in beeld; Robin noemt het terecht een bug, want FEP-633c
3.2 zegt gewoon dat een Undo van de Relationship de guardian eruit haalt.

Nu gaat er een Undo naar het kind en naar de andere guardians, met dezelfde
adressering als de Offer waarmee het begon (3.1.1), zodat geen kopie achterblijft
die denkt dat de band er nog is. De ontvangende kant haalt de guardian eruit,
maar alleen als de guardian zelf tekent: de variant waarbij het kind opzegt met
een mede-ondertekenende guardian heeft een tweede handtekening nodig en is niet
gebouwd, dus die wordt geweigerd in plaats van half uitgevoerd.

De laatste guardian kan niet meer alleen weglopen. 3.3 geldt zolang er meer dan
een over is; de set leegmaken is emancipatie (3.4) en daar gaat geen enkele
partij alleen over. Dat wordt geweigerd aan beide kanten, en de knop biedt in
dat geval alleen nog een nee aan in plaats van een ja die toch een fout geeft.

Een kind op DEZELFDE instance kreeg de Undo niet: een inbox op deze machine is
van deze machine niet over HTTP bereikbaar, en dat hoort ook niet. De commit-kant
lost dat al zo op dat elke instance schrijft wat hij host; het loslaten doet dat
nu ook. In de browser gevonden nadat de guardian-kant leeg was en de kant van het
kind nog niet.

Een guardian-app kan hetzelfde over C2S: een Undo naar de eigen outbox loopt
langs precies dezelfde functie als de knop in de PWA, zodat die twee niet uit
elkaar kunnen groeien.

Changed files:
src/services/guardianship/handshake.js

  • endGuardianship: bouwt en verstuurt de Undo, weigert emancipatie, en schrijft de kant van een lokaal gehost kind zelf
  • applyInboundUndo + dropGuardianFromWard: de ontvangende kant
  • handleOutbox accepteert Undo; handleInbox routeert hem

src/services/guardianship/index.js

  • endGuardianship en parseUndoRelationship geexporteerd

src/services/ActivityPubService.js

  • de guardianship-dispatch ziet Undo nu voordat de generieke Undo-tak hem opslokt met een 202
  • push voor een vertrokken guardian en een vertrokken mede-guardian

src/routes/guardian.js

  • /wards/remove loopt langs endGuardianship in plaats van een lokale delete
  • release-check meldt niet langer dat de Undo blijft liggen

src/assets/js/guardian.js

  • geen ja-knop meer als jij de laatste bent; een 409 wordt getoond in plaats van stil hertekend

src/services/i18n.js

  • de waarschuwing klopt weer, plus push-teksten in nl, en, de

test/guardianship.test.js

  • zes tests: de Undo werkt aan beide kanten, de laatste guardian wordt geweigerd, hij is idempotent, C2S loopt hetzelfde pad, een vreemde Undo verandert niets, en een kind op dezelfde instance wordt ook bijgewerkt terwijl er niets bezorgd is

remarks: end-to-end nagekeken in de browser: na het loslaten staat guard niet
meer in shaer:guardians van het actor-document van het kind, en een POST die de
knop omzeilt krijgt 409 would_emancipate.

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

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