source: Klonkt/src/services/guardianship/delivery.js@ a29c8c8

main
Last change on this file since a29c8c8 was a29c8c8, checked in by roboburr <roboburr@…>, 5 weeks ago

Een hulpvraag oppikken en afsluiten, zichtbaar voor alle guardians (shaer-lgo)

Een hulpvraag (5.2.1) gaat naar ALLE guardians van een kind, op verschillende
servers. Zonder gedeelde staat denken er twee dat de ander het oppakt -- precies
het scenario waar de reddingsboei voor bestaat. Tot nu toe droeg een hulpvraag
geen enkele staat: een vlag op ap_mentions, verder niets.

DE FAALSTAND IS HIER NIET VEILIG, en dat stuurt het hele ontwerp. Bij een gate is
"dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt dat het
geregeld is", en dat is gevaarlijker dan geen markering. Daarom staat een
hulpvraag bij twijfel OPEN: een lege lijst, een rij die we niet kunnen lezen, een
soort die we niet kennen -- alles wat geen expliciete afsluiting is telt als
"er wacht nog iemand".

Twee besluiten van Bart zitten in de vorm.

OPGEPIKT mag stapelen en vervalt niet, maar veroudert zichtbaar. Twee mensen die
tegelijk reageren op een kind is geen probleem; twee die allebei niets doen omdat
de ander het "geclaimd" had, wel. En een signaal dat vanzelf verdwijnt laat een
hulpvraag er onaangeroerd uitzien terwijl er iemand mee bezig is -- dus het blijft
staan en toont hoe oud het is.

AFGEHANDELD kent GEEN terugdraai. Sluiten gaat met een stevige bevestiging (geen
window.confirm, zelfde lijn als het loslaten van een ward), en leeft de vraag
daarna nog, dan wordt hij opnieuw gesteld -- een nieuwe hulpvraag. Er wordt niets
herschreven, er wordt toegevoegd. Een test bewaakt dat er geen undo() of
reopen() bestaat.

Geen nieuw protocol: de markering is een gewone directe note met een
shaer:-eigenschap, net als de zwaai en de afwezigheidsmelding. Daardoor reist hij
over de bestaande bezorging, ziet de WARD hem als bericht ("er komt iemand") en
houden de mede-guardians er staat aan over. Naar de ward en naar de andere
guardians tegelijk.

De eigen kopie wordt meteen weggeschreven, voordat er bezorgd is: het scherm van
degene die klikt hoort niet te liegen omdat een andere server traag is.

11 tests op het pure stuk. Gecontroleerd dat ze bijten: laat helpStatus altijd
"afgehandeld" zeggen en er vallen er vijf om. nl/en/de. Suite 576/576.

Niet gedekt: de weergave zelf (client-JS), en er is geen guardianship op dev om
het end-to-end te zien lopen.

  • Property mode set to 100644
File size: 8.7 KB
Line 
1/**
2 * Guardianship (FEP-633c) — the direct-note delivery leg.
3 *
4 * A direct note (private mention, shaer-tqc) is the ward's call-for-help
5 * carrier: addressed to specific actors only, no Public, no followers
6 * fan-out. Moved here from ActivityPubService (guardianship refactor);
7 * behavior is unchanged.
8 *
9 * This module has NO import back into ActivityPubService: the AP helpers it
10 * needs (actor fetch, key material, delivery, note building) are provided
11 * once via wireDelivery(deps) at ActivityPubService load time.
12 */
13import crypto from 'crypto';
14import db from '../../config/database.js';
15import { carriesGuardians } from './context.js';
16
17const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
18
19let deps = null;
20/** Called once by ActivityPubService with the shared AP helpers. */
21export function wireDelivery(d) { deps = d; }
22
23// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
24// safest bucket they match.
25export function c2sVisibility(object) {
26 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
27 const to = arr(object.to), cc = arr(object.cc);
28 const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
29 const isFollowers = (x) => /\/followers\/?$/.test(x);
30 if (to.some(isPublic)) return 'public';
31 if (cc.some(isPublic)) return 'quiet';
32 if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
33 if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior
34 return 'direct';
35}
36
37// A direct note: a NEW conversation (or a direct reply) addressed to specific
38// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
39// list, delivered to exactly those inboxes: no followers fan-out, no Public,
40// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
41// guardian on any instance receives it as a private mention (the ward
42// call-for-help path).
43export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
44 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
45 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
46 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
47 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
48 if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
49 const me = actorId(base, site.slug);
50 // Resolve every recipient for a mention anchor + a delivery inbox.
51 const resolved = [];
52 const teapots = [];
53 for (const uri of list) {
54 // An actor we host is read from our own database, not fetched from our own
55 // hostname: that request has to leave the machine and come back, and when
56 // it does not, the recipient is silently dropped from the note. Everything
57 // that decides anything still runs below, for local and remote alike.
58 const a = (localActor && localActor(uri)) || await fetchActor(uri).catch(() => null);
59 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
60 // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
61 // guardians of its own goes nowhere. There is no grand-guardian, so we
62 // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
63 // this one target and keep delivering to the rest, because a malformed
64 // guardian must never cost a child the guardians who are fine.
65 //
66 // Only for a call for help. An ordinary direct note is not an escalation,
67 // and a ward is perfectly entitled to message another ward.
68 if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
69 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
70 }
71 if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
72 if (!resolved.length) {
73 // Every guardian was malformed. §4 does not say what to do here because
74 // §4.1 assumes there are others to continue to — but a ward whose whole
75 // safety net is broken has just called for help into nothing, which is the
76 // one outcome this FEP exists to prevent. Say so loudly; the caller can
77 // tell "nobody was reachable" from "nobody was valid".
78 if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
79 return null;
80 }
81 const mention = resolved.map((r) => {
82 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
83 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
84 }).join('');
85 // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
86 // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
87 // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
88 // een leeggepoetste editor mag geen leeg bericht versturen.
89 const richClean = html ? deps.sanitizeHtml(String(html)) : '';
90 const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
91 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
92 // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
93 // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
94 const content = rich
95 ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
96 : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
97 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
98 // Attachments: same rules as deliverReply (own /media/ uploads only,
99 // image/audio/video, max 4) — the help-buoy capture rides this.
100 const media = (Array.isArray(attachments) ? attachments : [])
101 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
102 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
103 .slice(0, 4)
104 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
105 const id = crypto.randomUUID();
106 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
107 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
108 .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
109 const row = getOutboxRow(id);
110 const note = buildReplyNote(base, site, row);
111 // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
112 // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
113 // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
114 // mede-guardians er staat aan over.
115 if (helpMark && helpMark.noteUri) {
116 note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
117 }
118 const create = {
119 '@context': AP_CONTEXT,
120 id: note.id + '#create', type: 'Create', actor: me,
121 published: note.published, to: note.to, cc: note.cc, object: note,
122 };
123 const keys = getOrCreateKeys(site.slug);
124 const keyId = `${me}#main-key`;
125 let delivered = 0;
126 // A recipient on this machine takes the loopback (deliverToActor), which
127 // hands the Create to the same inbox handler an HTTP POST would reach: the
128 // note is stored, the mention is stored, and a shaer:away on it is applied,
129 // all by the code that does it for everyone else. A hairpin POST to our own
130 // hostname is not that code path, it is a second one that only appears to be.
131 for (const r of resolved.filter((x) => x.local)) {
132 const res = await deliverTo(site, r.uri, create).catch(() => null);
133 if (res && res.delivered) delivered++;
134 }
135 // Remote: one POST per inbox, so two guardians on the same server share it.
136 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
137 let ok = false;
138 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
139 if (ok) delivered++;
140 else enqueueDelivery(site.slug, inbox, create);
141 }
142 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
143 return { id, content, delivered, teapots };
144}
145
146export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.