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

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

Voorstellen vanuit de app, en een kind dat zelf kan vragen (shaer-8ru)

Barts opdracht: bouw shaer-8ru zodat de apps ook kunnen voorstellen, en maak
daarnaast het pad voor de ward om zelf een voorstel aan te zwengelen als hij
tegen een gated feature aanloopt.

DEEL 1 -- DE APPS KUNNEN VOORSTELLEN. De outbox kent nu een Offer van een
shaer:GatedSetting, precies de vorm die 5.6 al beschrijft en die de inbox al las.
De afweging zelf is verhuisd naar AP.proposeGate, waar de PWA-route nu ook
doorheen loopt. Twee implementaties naast elkaar zou een tweede weg naar
hetzelfde besluit zijn -- exact de fout die we vanmiddag bij de antwoordpoort
rechtzetten, toen de innamepoort alleen in C2S bleek te zitten.

DEEL 2 -- HET KIND KRIJGT WOORDEN. Tot nu toe liep alles over de guardians: zij
zien de catalogus, zij stellen voor, zij tellen. Het kind liep tegen een dichte
deur en kon niets. Nu stuurt het een shaer:gateRequest, en die landt bij zijn
guardians BIJ DE POORT waar hij over gaat -- niet in een aparte lijst die je
apart moet openen en dus vergeet.

EEN VRAAG IS GEEN STEM, en dat is de hele grens. Het verzoek is geen voorstel en
telt nergens mee; pas als een guardian het oppakt wordt het een voorstel dat
langs de gewone tally gaat. Anders opent een kind zijn eigen poort door hard
genoeg te vragen. Er staat een toets op die kijkt of er echt geen voorstel en
geen open poort uit ontstaat.

NIET DE REDDINGSBOEI. Een hulpvraag is een noodgeval en gaat door elke dichte
deur; dit is een wens. Ze door elkaar laten lopen zou de boei devalueren tot "het
kind wil iets", en dan kijkt er op een dag niemand meer op als hij afgaat.

GEEN VRIJE TEKST, en dat is geen gierigheid maar de reden dat het verzoek langs
de messages-poort MAG. Een kind met berichten dicht kan nog steeds om iets
vragen, zonder dat er een kanaal ontstaat om omheen die poort te praten. Wil het
uitleggen waarom, dan is dat een gesprek, en gesprekken hebben hun eigen poort.

Alleen van een eigen ward: een verzoek van een vreemde is geen vraag maar een
onbekende die iets over jouw instellingen wil zeggen.

Twaalf toetsen. Twee mutaties gecontroleerd (1 en 6 rood). Suite 712/712.

  • Property mode set to 100644
File size: 8.9 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, gateRequest }) {
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 // Een kind dat zelf om een poort vraagt (shaer-8ru). Alleen de naam van de
119 // feature reist mee -- geen vrije tekst, zie gatereq.js.
120 if (gateRequest) note['shaer:gateRequest'] = String(gateRequest);
121 const create = {
122 '@context': AP_CONTEXT,
123 id: note.id + '#create', type: 'Create', actor: me,
124 published: note.published, to: note.to, cc: note.cc, object: note,
125 };
126 const keys = getOrCreateKeys(site.slug);
127 const keyId = `${me}#main-key`;
128 let delivered = 0;
129 // A recipient on this machine takes the loopback (deliverToActor), which
130 // hands the Create to the same inbox handler an HTTP POST would reach: the
131 // note is stored, the mention is stored, and a shaer:away on it is applied,
132 // all by the code that does it for everyone else. A hairpin POST to our own
133 // hostname is not that code path, it is a second one that only appears to be.
134 for (const r of resolved.filter((x) => x.local)) {
135 const res = await deliverTo(site, r.uri, create).catch(() => null);
136 if (res && res.delivered) delivered++;
137 }
138 // Remote: one POST per inbox, so two guardians on the same server share it.
139 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
140 let ok = false;
141 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
142 if (ok) delivered++;
143 else enqueueDelivery(site.slug, inbox, create);
144 }
145 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
146 return { id, content, delivered, teapots };
147}
148
149export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.