source: Klonkt/src/services/guardianship/delivery.js@ 6e9bc70

main
Last change on this file since 6e9bc70 was 6e9bc70, checked in by Bart <bart@…>, 2 days ago

Zachte groepen: een gesprek is de set deelnemers, niet één tegenpartij

Een direct bericht aan Anna en Ben bestond al als groep -- to_actors droeg de
hele set -- maar elke lezing sleutelde op één other. Een uitgaand bericht
aan twee mensen viel in twee gesprekken uiteen, en een binnenkomend bericht
van Anna aan jou en Ben kwam alleen bij Anna terecht: Ben viel eruit, want de
adressering van een binnenkomer werd nergens bewaard.

Nu is er een sleutel, één keer berekend bij het schrijven
(conversation-key.js): voor één ander zijn kale URI, precies zoals het altijd
was, dus bestaande gesprekken, leesmarkeringen en ?with=-URL's blijven geldig
zonder migratie; voor een groep de gesorteerde set, gescheiden door een
spatie. Koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal
via dezelfde unie, en werken daarmee per gesprek zonder dat er in die queries
iets hoefde te veranderen. Oude rijen hebben geen sleutel en lezen zoals ze
altijd lazen.

Alleen directe berichten groeperen: een openbare post aan jou en Ben is geen
besloten gesprek met z'n drieën.

/messages neemt meer dan één with, en elk item draagt shaer:conversation en
shaer:participants. Uitgaande directe notes krijgen een FEP-7888 context --
die van de ouder als het een antwoord is, anders een nieuwe -- die bewaard en
uitgestuurd wordt, maar geen bubbels samenvoegt: een bubbel die Carla toont
terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.

shaer-mbji
Co-Authored-By: Claude Opus 5.5 <claude@…>

  • Property mode set to 100644
File size: 12.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, { NU_ISO } from '../../config/database.js';
15import { carriesGuardians } from './context.js';
16import * as ConversationKey from '../conversation-key.js';
17
18const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
19
20let deps = null;
21/** Called once by ActivityPubService with the shared AP helpers. */
22export function wireDelivery(d) { deps = d; }
23
24// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
25// safest bucket they match.
26export function c2sVisibility(object) {
27 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
28 const to = arr(object.to), cc = arr(object.cc);
29 const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
30 const isFollowers = (x) => /\/followers\/?$/.test(x);
31 if (to.some(isPublic)) return 'public';
32 if (cc.some(isPublic)) return 'quiet';
33 if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
34 if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior
35 return 'direct';
36}
37
38/**
39 * De context (FEP-7888) van het bericht waarop geantwoord wordt, als we die
40 * kennen: uit wat binnenkwam of uit wat we zelf stuurden. Een antwoord hoort in
41 * het gesprek van zijn ouder, en niet in een vers gesprek ernaast.
42 */
43function contextFor(slug, inReplyTo) {
44 if (!inReplyTo) return null;
45 try {
46 const binnen = db.prepare('SELECT context FROM ap_mentions WHERE slug = ? AND object_uri = ? AND context IS NOT NULL')
47 .get(slug, inReplyTo);
48 if (binnen && binnen.context) return binnen.context;
49 // Een eigen note heeft als uri .../ap/notes/<id>; de id is de outbox-sleutel.
50 const eigen = /\/ap\/notes\/([^/?#]+)$/.exec(String(inReplyTo));
51 if (eigen) {
52 const rij = db.prepare('SELECT context FROM ap_outbox WHERE site_slug = ? AND id = ? AND context IS NOT NULL')
53 .get(slug, eigen[1]);
54 if (rij && rij.context) return rij.context;
55 }
56 } catch { /* geen context is ook een antwoord */ }
57 return null;
58}
59
60/**
61 * Heeft dit directe bericht iets te zeggen? Puur, zodat de regel te toetsen is
62 * zonder database, netwerk of scherm.
63 *
64 * EEN FOTO KAN HET HELE BERICHT ZIJN. Hier stond alleen `!text.trim()`, en dat
65 * betekende: een direct bericht zonder woorden gaf null terug, waar de inname
66 * "502 direct_failed" van maakte. Precies wat je kreeg als je een foto op een
67 * gesprek liet vallen en niets typte -- de inname liet een bijlage-only note
68 * door, en deze laag eronder weigerde hem alsnog. Twee lagen die het niet eens
69 * waren over wat een bericht is.
70 *
71 * Een leeg bericht blijft geweigerd: geen tekst, geen opmaak en geen bijlage is
72 * niets, en dat hoort niet de deur uit te gaan.
73 */
74export function directNoteHasContent({ text, html, attachments } = {}) {
75 if (String(text || '').trim()) return true;
76 if (String(html || '').trim()) return true;
77 return Array.isArray(attachments) && attachments.length > 0;
78}
79
80// A direct note: a NEW conversation (or a direct reply) addressed to specific
81// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
82// list, delivered to exactly those inboxes: no followers fan-out, no Public,
83// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
84// guardian on any instance receives it as a private mention (the ward
85// call-for-help path).
86export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark, gateRequest }) {
87 // EEN MARKERING IS EEN ANTWOORD op de hulpvraag waar hij over gaat (Barts
88 // vraag, 26-8: "welke context heeft 'Ik kijk hiernaar' als ik erop klik?" --
89 // geen). De verwijzing reisde al mee als shaer:-veld, maar dat veld haalt de
90 // berichtenlezing niet, en de tik-route van de clients volgt inReplyTo.
91 // Dus zeggen we het ook in gewoon AS2: dan opent een tik de draad met de
92 // schermafdruk erbij, en threaden andere fediverse-servers hem net zo goed.
93 if (!inReplyTo && helpMark && helpMark.noteUri) inReplyTo = helpMark.noteUri;
94 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
95 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
96 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
97 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
98 if (!base || !site || !site.slug || !list.length) return null;
99 if (!directNoteHasContent({ text, html, attachments })) return null;
100 const me = actorId(base, site.slug);
101 // Resolve every recipient for a mention anchor + a delivery inbox.
102 const resolved = [];
103 const teapots = [];
104 for (const uri of list) {
105 // An actor we host is read from our own database, not fetched from our own
106 // hostname: that request has to leave the machine and come back, and when
107 // it does not, the recipient is silently dropped from the note. Everything
108 // that decides anything still runs below, for local and remote alike.
109 // ONDERTEKEND ophalen als het onbetekend niet lukt (asSlug). Een instance
110 // met Mastodons secure mode -- infosec.exchange bijvoorbeeld -- antwoordt
111 // 401 op een anonieme GET van het actor-document. Zonder document geen
112 // inbox, dus viel de ontvanger hier stil weg, en met de laatste ontvanger
113 // gaf deliverDirectNote null terug: "502 direct_failed", zonder te zeggen
114 // wie er niet bereikbaar was.
115 //
116 // Dezelfde les als bij het volgen vanaf een boost (Robins melding, 31-7):
117 // die weg kreeg toen signedGetJson, deze niet. fetchActor probeert nog
118 // steeds ONBETEKEND eerst -- dat blijft de veiligheidskeuze -- en tekent
119 // alleen deze ene URL als dat mislukt.
120 const a = (localActor && localActor(uri))
121 || await fetchActor(uri, { asSlug: site.slug }).catch(() => null);
122 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
123 // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
124 // guardians of its own goes nowhere. There is no grand-guardian, so we
125 // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
126 // this one target and keep delivering to the rest, because a malformed
127 // guardian must never cost a child the guardians who are fine.
128 //
129 // Only for a call for help. An ordinary direct note is not an escalation,
130 // and a ward is perfectly entitled to message another ward.
131 if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
132 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
133 }
134 if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
135 if (!resolved.length) {
136 // Every guardian was malformed. §4 does not say what to do here because
137 // §4.1 assumes there are others to continue to — but a ward whose whole
138 // safety net is broken has just called for help into nothing, which is the
139 // one outcome this FEP exists to prevent. Say so loudly; the caller can
140 // tell "nobody was reachable" from "nobody was valid".
141 if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
142 return null;
143 }
144 const mention = resolved.map((r) => {
145 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
146 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
147 }).join('');
148 // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
149 // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
150 // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
151 // een leeggepoetste editor mag geen leeg bericht versturen.
152 const richClean = html ? deps.sanitizeHtml(String(html)) : '';
153 const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
154 const body = escHtml(String(text || '').trim()).replace(/\r?\n/g, '<br>');
155 // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
156 // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
157 const content = rich
158 ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
159 : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
160 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
161 // Attachments: same rules as deliverReply (own /media/ uploads only,
162 // image/audio/video, max 4) — the help-buoy capture rides this.
163 const media = (Array.isArray(attachments) ? attachments : [])
164 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
165 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
166 .slice(0, 4)
167 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
168 const id = crypto.randomUUID();
169 // Het gesprek waar dit bij hoort (shaer-mbji): de set ontvangers. Aan Anna en
170 // Ben samen is één gesprek, en niet twee die elk de helft dragen.
171 const conv = ConversationKey.conversationKey(resolved.map((r) => r.uri));
172 // FEP-7888: het gesprek een naam geven die andere servers ook lezen. Is dit
173 // een antwoord op iets met een context, dan hoort het in diezelfde; anders
174 // begint hier een nieuwe.
175 const context = contextFor(site.slug, inReplyTo) || `${base}/ap/contexts/${crypto.randomUUID()}`;
176 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, conv, context, created_at)
177 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
178 .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, conv, context);
179 const row = getOutboxRow(id);
180 const note = buildReplyNote(base, site, row);
181 // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
182 // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
183 // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
184 // mede-guardians er staat aan over.
185 if (helpMark && helpMark.noteUri) {
186 note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
187 }
188 // Een kind dat zelf om een poort vraagt (shaer-8ru). Alleen de naam van de
189 // feature reist mee -- geen vrije tekst, zie gatereq.js.
190 if (gateRequest) note['shaer:gateRequest'] = String(gateRequest);
191 const create = {
192 '@context': AP_CONTEXT,
193 id: note.id + '#create', type: 'Create', actor: me,
194 published: note.published, to: note.to, cc: note.cc, object: note,
195 };
196 const keys = getOrCreateKeys(site.slug);
197 const keyId = `${me}#main-key`;
198 let delivered = 0;
199 // A recipient on this machine takes the loopback (deliverToActor), which
200 // hands the Create to the same inbox handler an HTTP POST would reach: the
201 // note is stored, the mention is stored, and a shaer:away on it is applied,
202 // all by the code that does it for everyone else. A hairpin POST to our own
203 // hostname is not that code path, it is a second one that only appears to be.
204 for (const r of resolved.filter((x) => x.local)) {
205 const res = await deliverTo(site, r.uri, create).catch(() => null);
206 if (res && res.delivered) delivered++;
207 }
208 // Remote: one POST per inbox, so two guardians on the same server share it.
209 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
210 let ok = false;
211 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
212 if (ok) delivered++;
213 else enqueueDelivery(site.slug, inbox, create);
214 }
215 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
216 return { id, content, delivered, teapots };
217}
218
219export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.