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

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

Beschikbaarheid van guardians (FEP-633c 3.6): away, dormant, lapse

De Klonkt-kant van het beschikbaarheidsvoorstel, nagemaakt zoals eerst in de
daemon gevalideerd (shaer-8z7): dezelfde toestanden, dezelfde regels, dezelfde
weigeringen. De spiegel-tests dragen dezelfde namen als de daemon-tests, zodat
drift tussen de twee backends opvalt als een falende test met dezelfde woorden.

De kern is guardianship/availability.js: drie toestanden per (ward, guardian),
met als regel boven alles dat een antwoord alles herstelt, tot en met een
lopende lapse. Elke geverifieerde inbox-activiteit en elke C2S-handeling van
een guardian herstelt hem en annuleert een lapse tegen hem, nog voor er naar de
activiteit gekeken wordt. Bewust achter de handtekening-poort: een ongeverifieerde
bewering oma te zijn mag oma niet wakker maken.

Afwezig komt binnen over beide wegen: S2S als directe note met shaer:away en
endTime van een guardian elders (het gewone geval), en C2S als een guardian
hier zich afmeldt; die note draagt de marker mee naar wards elders en wordt
voor wards op deze instance direct toegepast, want een lokale inbox ontvangt
zijn eigen bezorging niet. Zonder (toekomstig) einde faalt het luid met 400,
precies zoals de daemon weigert.

Slapend volgt alleen uit onbeantwoorde direct geadresseerde verzoeken; de
follow-gating registreert die nu als bewijs. De markering notificeert verplicht
via protocol en de 6-handle, eenmalig op de overgang, centraal bedraad zodat
elke plek waar een promotie kan gebeuren hetzelfde notificeert.

De drempel van 3.5 rekent voortaan over de beschikbare set: de follow-quorums
en de gated settings allebei. De test die het waarom draagt: vijf guardians van
wie twee weg zijn gaven een drempel van drie die de twee levenden nooit haalden;
over de beschikbare set beslissen zij weer.

De lapse loopt over dezelfde draden als de gated settings: een Offer van
shaer:Lapse opent op de server van het kind, Accept/Reject stemt, het venster
loopt altijd vol, en de voltooiing verwijdert de relatie met de
nooit-leeg-grens uit 3.4 als tweede slot eronder. De offers-queue draagt de
lopende lapses en de nieuwe owner-only guardians-queue de beschikbaarheid, in
precies de vorm die de daemon serveert, dus de Shaer-apps van gisteren werken
zonder wijziging.

Changed files:
src/config/database.js

  • tabellen ap_guardian_attention, ap_attention_requests, ap_lapses
  • kolom ap_outbox.away_until

src/services/guardianship/handshake.js

  • Offer van shaer:Lapse (S2S en C2S), lapse-stemmen op Accept/Reject, one-answer op elke C2S-handeling

src/services/guardianship/gated.js

  • tally en voortgang over de beschikbare set; een stem is een antwoord

src/services/guardianship/notes.js

  • awayProps: shaer:away plus endTime op de uitgaande directe note

src/services/guardianship/delivery.js

  • away_until door het directe pad heen

src/services/guardianship/queues.js

  • guardiansCollection; offersCollection draagt de lapses

src/services/guardianship/index.js

  • exports

src/services/ActivityPubService.js

  • one-answer achter de handtekening-poort
  • away-ingest op het mention-pad en het C2S-directe pad
  • dormancy-bewijs op de follow-gating; quorum over de beschikbare set
  • de notificatieplicht van 3.6.2, een keer bedraad
  • buildReplyNote draagt awayProps

src/routes/activitypub.js

  • owner-only route /queues/guardians

src/routes/guardian.js

  • dashboard-besluit is een antwoord; quorum over de beschikbare set

src/services/guardianship/relations.js

  • guardians-queue aangekondigd in shaer:queues

test/activitypub-as2.test.js

  • guardians toegevoegd aan de queue-sleutels

New file:
src/services/guardianship/availability.js

  • de toestandsmachine, de lapse en de endTime-parser

test/availability.test.js

  • veertien spiegel-tests van de daemon, tot en met de volle lapse-flow over de S2S-draad en het vijf-guardians-rekenvoorbeeld

remarks: de PWA toont de beschikbaarheid nog niet (chips in het paneel per
kind en een lapse-kaart komen apart); de echte kruis-implementatie-testbank
blijft open op shaer-6d9. Klonkt heeft geen pinbare klok zoals de daemon; de
tests dateren bewijs terug in plaats van de tijd vooruit te zetten, en dat
staat er als kanttekening bij. Niet uitgerold.

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

  • Property mode set to 100644
File size: 26.2 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 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;
55}
56
57function dashboardState(site, L) {
58 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
59 const me = AP.actorId(base, site.slug);
60 const help = db.prepare(
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
63 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
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),
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),
74 }));
75 return {
76 site: site.slug,
77 me,
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) })),
82 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
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);
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).
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,
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));
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
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,
148 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
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,
154 when_text: formatDateTime(p.published || p.created_at),
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)
167 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
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 ''; } })();
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.
178 // Local wards (guardian co-located): read the pending follows directly.
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 });
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; } })();
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 });
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 allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
213 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
214 // Acting from the dashboard is an answer (3.6), and the quorum runs over
215 // the available set (3.5): both applied here, the same as over the wire.
216 Guardianship.availability.oneAnswer(me, Date.now());
217 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
218 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
219 try {
220 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
221 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
222 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
223 res.json({ ok: true, outcome: r.outcome });
224});
225
226// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
227// guardian to a ward. A private direct note, never a feed post. Warmth
228// without publishing (Robins besluit).
229router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
230 const site = siteForUser(req);
231 if (!site) return res.status(404).json({ error: 'no_site' });
232 const wardUri = String(req.body?.ward || '').trim();
233 // Only wave at a ward you actually guard.
234 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
235 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
236 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
237 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
238 if (!r) return res.status(502).json({ error: 'delivery' });
239 res.json({ ok: true, delivered: r.delivered });
240});
241
242// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
243// the Shaer apps use (one path, one behavior).
244router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
245 const site = siteForUser(req);
246 if (!site) return res.status(404).json({ error: 'no_site' });
247 const handle = String(req.body?.handle || '').trim();
248 if (!handle) return res.status(400).json({ error: 'empty_handle' });
249 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
250 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
251 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
252 const me = AP.actorId(base, site.slug);
253 const r = await AP.ingestOutboxActivity(site, req.session.user, {
254 type: 'Offer',
255 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
256 });
257 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
258 // offer is recorded and delivery is retried in the background.
259 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
260 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
261});
262
263// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
264// "complete"). All three are a C2S Accept/Reject on the offer id; the
265// handshake module decides when it commits (ยง3.1).
266router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
267 const site = siteForUser(req);
268 if (!site) return res.status(404).json({ error: 'no_site' });
269 const offerId = String(req.body?.offer || '').trim();
270 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
271 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
272 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
273 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
274 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
275});
276
277// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
278// /assets cache or a stuck install (that was the whole "nothing works after
279// a deploy" bug). Small files; the browser revalidates and gets a 304 when
280// unchanged, the fresh file when changed.
281function pwaAsset(rel, type) {
282 return (req, res) => {
283 res.set('Cache-Control', 'no-cache');
284 res.type(type);
285 res.sendFile(path.join(__dir, '..', 'assets', rel));
286 };
287}
288router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
289router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
290
291// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
292/**
293 * What actually happens if this guardian releases this ward?
294 *
295 * Releasing is not one action but two very different ones, and the difference
296 * is the number of guardians the child has left (FEP-633c):
297 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
298 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
299 * that no single guardian decides it alone (three consenting adults, or a
300 * majority plus two witnesses).
301 * On top of that, today's release is LOCAL: the Undo is not federated yet
302 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
303 * A guardian pressing the button would otherwise believe the child is released.
304 *
305 * Answered on demand rather than in the dashboard state: for a ward we do not
306 * host this reaches out to that ward's server, and nobody should pay for that
307 * on every refresh.
308 */
309router.get('/wards/release-check', requireAuth, async (req, res) => {
310 const site = siteForUser(req);
311 if (!site) return res.status(404).json({ error: 'no_site' });
312 const uri = String(req.query.uri || '').trim();
313 if (!uri) return res.status(400).json({ error: 'empty_uri' });
314 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
315 return res.status(403).json({ error: 'not_my_ward' });
316 }
317 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
318 const local = !!base && uri.startsWith(`${base}/`);
319 let guardians = null; // null = we could not find out; say so rather than guess
320 if (local) {
321 const slug = uri.replace(/\/+$/, '').split('/').pop();
322 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
323 } else {
324 const doc = await AP.fetchActor(uri).catch(() => null);
325 const g = doc && doc['shaer:guardians'];
326 if (Array.isArray(g)) guardians = g.length;
327 else if (typeof g === 'string') guardians = 1;
328 else if (g && Array.isArray(g.items)) guardians = g.items.length;
329 else if (doc) guardians = 0; // the actor answered and names no guardians
330 }
331 res.json({
332 guardians,
333 last: guardians === null ? null : guardians <= 1,
334 local,
335 });
336});
337
338router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
339 const site = siteForUser(req);
340 if (!site) return res.status(404).json({ error: 'no_site' });
341 const uri = String(req.body?.uri || '').trim();
342 if (!uri) return res.status(400).json({ error: 'empty_uri' });
343 // Ending a guardianship is an Undo of the Relationship that travels to the
344 // ward and the other guardians (ยง3.2), not a local delete. Same call the
345 // Guardian apps reach over C2S, so the two cannot drift apart.
346 const r = await Guardianship.endGuardianship(site, uri);
347 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
348 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
349});
350
351/**
352 * The external-embeds setting of a ward we host: true/false when a guardian has
353 * decided, null when it is still on auto (which means off for a ward) or when
354 * the ward lives elsewhere and the setting is not ours to show.
355 */
356function wardEmbedSetting(uri) {
357 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
358 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
359 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
360 const row = slug ? db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(slug) : null;
361 if (!row) return null;
362 return row.external_embeds === null || row.external_embeds === undefined ? false : row.external_embeds === 1;
363}
364
365// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
366// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
367// server-side when the feed is serialised, so this endpoint is the only way it
368// can move, and only a committed guardian of THAT ward may move it.
369router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
370 const site = siteForUser(req);
371 if (!site) return res.status(404).json({ error: 'no_site' });
372 const uri = String(req.body?.uri || '').trim();
373 const allow = req.body?.allow === true;
374 if (!uri) return res.status(400).json({ error: 'empty_uri' });
375 // Only a guardian of this ward, and only for a ward we host: a setting on a
376 // remote ward belongs to that ward's own server (federating it is Fase 4).
377 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
378 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
379 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
380 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
381 // just the case where that server happens to be this one, so it takes the
382 // same road: propose, then let the tally decide. Anything else would make a
383 // guardian on the ward's own instance more powerful than one elsewhere.
384 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
385 const me = AP.actorId(base, site.slug);
386 const feature = 'shaer:externalEmbeds';
387 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
388 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
389 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
390 const localWard = localSlug ? db.prepare('SELECT slug FROM sites WHERE slug = ?').get(localSlug) : null;
391 if (localWard) {
392 Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow);
393 const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow);
394 return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of });
395 }
396 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
397 res.json({ ok: true, allow, state: 'open', federated: true });
398});
399
400// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
401// its own app next to the site PWA.
402router.get('/manifest.webmanifest', (req, res) => {
403 const site = res.locals.site;
404 res.set('Cache-Control', 'no-cache');
405 res.json({
406 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
407 name: 'Klonkt Guardian',
408 short_name: 'Guardian',
409 description: 'Ward management and help requests for guardians.',
410 scope: '/guardian/',
411 start_url: '/guardian?source=pwa',
412 display: 'standalone',
413 display_override: ['standalone', 'minimal-ui'],
414 orientation: 'any',
415 background_color: '#141a24',
416 theme_color: '#ff6b35',
417 lang: site?.language || 'nl',
418 icons: [
419 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
420 ],
421 });
422});
423
424// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
425router.get('/icon.svg', (req, res) => {
426 const svg = `<?xml version="1.0" encoding="UTF-8"?>
427<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
428 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
429 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
430</svg>`;
431 res.set('Content-Type', 'image/svg+xml');
432 res.set('Cache-Control', 'public, max-age=86400');
433 res.send(svg);
434});
435
436// โ”€โ”€ Losse guardians (Guardian 2): uitnodigen en aansluiten โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
437// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
438// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
439// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
440
441router.post('/invite', requireAuth, (req, res) => {
442 const token = crypto.randomBytes(16).toString('base64url');
443 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
444 .run(token, req.session.user.id);
445 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
446 const url = `${base}/guardian/join/${token}`;
447 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
448 <h2>Invite a guardian</h2>
449 <p>Share this link. It lets one person create a guardian account here:</p>
450 <p><a href="${url}">${url}</a></p>
451 <p><a href="/guardian">Back</a></p></body>`);
452});
453
454function joinForm(token, error) {
455 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
456 <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
457 <h2>Become a guardian</h2>
458 <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
459 ${error ? `<p style="color:#b00">${error}</p>` : ''}
460 <form method="post" action="/guardian/join/${token}">
461 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
462 style="width:100%;padding:10px" autocapitalize="none"></p>
463 <p><input name="password" type="password" placeholder="password" required minlength="8"
464 style="width:100%;padding:10px"></p>
465 <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
466 </form></body>`;
467}
468
469router.get('/join/:token', (req, res) => {
470 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
471 .get(req.params.token);
472 if (!inv) return res.status(404).send('This invite is no longer valid.');
473 res.send(joinForm(req.params.token));
474});
475
476router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
477 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
478 .get(req.params.token);
479 if (!inv) return res.status(404).send('This invite is no longer valid.');
480 const name = String(req.body.name || '').trim().toLowerCase();
481 const password = String(req.body.password || '');
482 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
483 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
484 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
485 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
486 }
487 const userId = crypto.randomUUID();
488 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
489 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
490 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
491 .run(crypto.randomUUID(), name, name, userId);
492 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
493 .run(userId, req.params.token);
494 req.session.user = { id: userId, username: name, role: 'member' };
495 res.redirect('/guardian');
496});
497
498export default router;
Note: See TracBrowser for help on using the repository browser.