source: Klonkt/src/routes/guardian.js@ fcd6964

main
Last change on this file since fcd6964 was fcd6964, checked in by Robin <roboburr@…>, 7 weeks ago

Guardianship: ward ziet z'n guardians, PWA-assets no-cache, handle-fix

De handshake commit werkte al aan beide kanten (geverifieerd live: sound-fabrics
ziet beta als ward, beta weet dat sound-fabrics guardian is), maar het TONEN
ontbrak op drie plekken. Dit lost de Klonkt-kant op.

  1. Ward-kant: in Berichten ziet een ward nu wie z'n guardians zijn (committed relaties, FEP-633c §2). Die view bestond nog niet.
  2. other_handle bewaarde per ongeluk de FEP-escalatiehandle (candidate/inbox) i.p.v. de @handle, dus overal waar we het toonden kwam een inbox-URL uit. Nu de echte @handle uit de offer; display self-healt oude rijen door @user@host uit de actor-URI af te leiden als de opgeslagen handle geen @ is.
  3. Guardian PWA-assets via /guardian/app.js|css met Cache-Control: no-cache, dus een update wordt nooit meer gemaskeerd door de 1-jaar /assets-cache of een vastgelopen install (dat was de "niks werkt na deploy"-bug). Client zit nu in try/catch met een zichtbare foutbanner i.p.v. stil te hangen.

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

  • applyCommitLocally schrijft de @handle (offer.ward/candidate_handle) naar other_handle

src/routes/posts.js

  • messages-route levert myGuardians (committed guardians met afgeleide @handle)

src/views/pages/messages.ejs

  • "Jouw guardians"-sectie voor de ward

src/services/i18n.js

  • msg.guardians_label (nl/en/de)

src/routes/guardian.js

  • /guardian/app.js|css no-cache; ASSET_V-mtime-machinerie weg

src/assets/js/guardian.js

  • hele client in try/catch met zichtbare foutbanner; handleOf vertrouwt alleen echte @handles

src/views/pages/guardian.ejs

  • assets via no-cache routes i.p.v. /assets?v=

test/guardianship.test.js

  • assert dat other_handle de @handle is na commit

remarks: getest met npm test (6/6). sound-fabrics (stable-lane, handmatig) bewust
niet aangeraakt; wacht op akkoord.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.0 KB
Line 
1/**
2 * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
3 * guardians. One place to add and manage wards, a message centre for
4 * incoming help requests and adoption traffic, and its own push channel
5 * (alert types 'help' and 'guardian', web-push slice reused).
6 *
7 * Everything is scoped to a site the logged-in user OWNS: the guardian acts
8 * as one of their own actors (?site=slug picks one when they own several).
9 * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
10 */
11import express from 'express';
12import path from 'path';
13import { fileURLToPath } from 'url';
14import db from '../config/database.js';
15import { requireAuth } from '../middleware/auth.js';
16import AP from '../services/ActivityPubService.js';
17import * as Guardianship from '../services/guardianship/index.js';
18import { t as i18nT, resolveLang } from '../services/i18n.js';
19
20const router = express.Router();
21const __dir = path.dirname(fileURLToPath(import.meta.url));
22
23/** The acting site: ?site=slug when owned, else the user's first site. */
24function siteForUser(req) {
25 const userId = req.session.user.id;
26 const want = String(req.query.site || req.body?.site || '').trim();
27 if (want) {
28 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
29 if (s) return s;
30 }
31 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
32}
33
34/** Everything the dashboard shows, one shape for page and API. */
35function uiStrings(L) {
36 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
37 'pending', 'active', 'retract', 'release', 'open', 'push_unavailable',
38 'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
39 return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
40}
41
42function dashboardState(site, L) {
43 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
44 const me = AP.actorId(base, site.slug);
45 const help = db.prepare(
46 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
47 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
48 ).all(site.slug);
49 return {
50 site: site.slug,
51 me,
52 wards: Guardianship.listWards(site.slug), // committed wards
53 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
54 help,
55 strings: uiStrings(L),
56 };
57}
58
59// ── The PWA page ─────────────────────────────────────────────────────────
60router.get('/', requireAuth, (req, res) => {
61 const site = siteForUser(req);
62 const L = resolveLang(req);
63 if (!site) return res.status(404).send('No site for this account.');
64 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
65 res.render('pages/guardian', {
66 state: dashboardState(site, L),
67 sites,
68 lang: L,
69 t: (k, v) => i18nT(L, k, v),
70 cspNonce: res.locals.cspNonce,
71 });
72});
73
74// ── JSON state for refreshes ─────────────────────────────────────────────
75router.get('/api/state', requireAuth, (req, res) => {
76 const site = siteForUser(req);
77 if (!site) return res.status(404).json({ error: 'no_site' });
78 res.json(dashboardState(site, resolveLang(req)));
79});
80
81// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
82// the Shaer apps use (one path, one behavior).
83router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
84 const site = siteForUser(req);
85 if (!site) return res.status(404).json({ error: 'no_site' });
86 const handle = String(req.body?.handle || '').trim();
87 if (!handle) return res.status(400).json({ error: 'empty_handle' });
88 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
89 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
90 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
91 const me = AP.actorId(base, site.slug);
92 const r = await AP.ingestOutboxActivity(site, req.session.user, {
93 type: 'Offer',
94 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
95 });
96 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
97 // offer is recorded and delivery is retried in the background.
98 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
99 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
100});
101
102// ── Answer an offer (co-guardian accept/reject, or the candidate's final
103// "complete"). All three are a C2S Accept/Reject on the offer id; the
104// handshake module decides when it commits (§3.1).
105router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
106 const site = siteForUser(req);
107 if (!site) return res.status(404).json({ error: 'no_site' });
108 const offerId = String(req.body?.offer || '').trim();
109 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
110 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
111 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
112 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
113 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
114});
115
116// ── PWA assets served no-cache, so an update is never masked by the 1-year
117// /assets cache or a stuck install (that was the whole "nothing works after
118// a deploy" bug). Small files; the browser revalidates and gets a 304 when
119// unchanged, the fresh file when changed.
120function pwaAsset(rel, type) {
121 return (req, res) => {
122 res.set('Cache-Control', 'no-cache');
123 res.type(type);
124 res.sendFile(path.join(__dir, '..', 'assets', rel));
125 };
126}
127router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
128router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
129
130// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
131router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
132 const site = siteForUser(req);
133 if (!site) return res.status(404).json({ error: 'no_site' });
134 const uri = String(req.body?.uri || '').trim();
135 if (!uri) return res.status(400).json({ error: 'empty_uri' });
136 Guardianship.removeRelation(site.slug, 'guardian', uri);
137 res.json({ ok: true });
138});
139
140// ── The installable identity: own scope so the Guardian corner installs as
141// its own app next to the site PWA.
142router.get('/manifest.webmanifest', (req, res) => {
143 const site = res.locals.site;
144 res.set('Cache-Control', 'no-cache');
145 res.json({
146 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
147 name: 'Klonkt Guardian',
148 short_name: 'Guardian',
149 description: 'Ward management and help requests for guardians.',
150 scope: '/guardian/',
151 start_url: '/guardian?source=pwa',
152 display: 'standalone',
153 display_override: ['standalone', 'minimal-ui'],
154 orientation: 'any',
155 background_color: '#141a24',
156 theme_color: '#ff6b35',
157 lang: site?.language || 'nl',
158 icons: [
159 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
160 ],
161 });
162});
163
164// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
165router.get('/icon.svg', (req, res) => {
166 const svg = `<?xml version="1.0" encoding="UTF-8"?>
167<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
168 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
169 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
170</svg>`;
171 res.set('Content-Type', 'image/svg+xml');
172 res.set('Cache-Control', 'public, max-age=86400');
173 res.send(svg);
174});
175
176export default router;
Note: See TracBrowser for help on using the repository browser.