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