source: Klonkt/src/services/guardianship/delivery.js@ 3d882bd

main
Last change on this file since 3d882bd was 3d882bd, checked in by Bart <bart@…>, 5 weeks ago

FEP-633c §4.1: een kapotte guardian kost een kind niet de goede

Een "guardian" met eigen guardians is er geen (§1), en een escalatie daarheen
komt nergens aan: er is geen grand-guardian om naar door te vertakken. Klonkt
handhaafde dat nergens. De daemon doet het vanaf het begin, en dat verschil is
precies waar shaer-6d9 voor bestaat.

Nu zacht falen zoals §4.1 vraagt: dat ene doelwit valt af, de rest krijgt de
hulpvraag gewoon. Andersom zou één verkeerd geconfigureerd account van een
volwassene de noodroep van een kind helemaal laten mislukken.

Alleen bij een hulpvraag. Een gewoon direct bericht is geen escalatie, en een
ward mag een andere ward best iets sturen — daar stilletjes ontvangers uit
slopen zou een bug zijn met een spec-verwijzing eromheen.

Als ELKE guardian kapot is, is er niets om naar door te leveren. §4 dekt dat
niet, want §4.1 gaat ervan uit dat er anderen zijn. Dan komt de hulpvraag bij
niemand aan, en dat is het enige wat deze FEP juist moet voorkomen: dat faalt
dus luid in de log in plaats van een aflevering te melden die niet gebeurde.

carriesGuardians() staat nu in context.js, waar de rest van het vocabulaire ook
woont: §3 en §5.2 stellen dezelfde vraag en moeten hem hetzelfde lezen.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 7.6 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, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
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 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
86 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
87 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
88 // Attachments: same rules as deliverReply (own /media/ uploads only,
89 // image/audio/video, max 4) — the help-buoy capture rides this.
90 const media = (Array.isArray(attachments) ? attachments : [])
91 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
92 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
93 .slice(0, 4)
94 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
95 const id = crypto.randomUUID();
96 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)
97 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
98 .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);
99 const row = getOutboxRow(id);
100 const note = buildReplyNote(base, site, row);
101 const create = {
102 '@context': AP_CONTEXT,
103 id: note.id + '#create', type: 'Create', actor: me,
104 published: note.published, to: note.to, cc: note.cc, object: note,
105 };
106 const keys = getOrCreateKeys(site.slug);
107 const keyId = `${me}#main-key`;
108 let delivered = 0;
109 // A recipient on this machine takes the loopback (deliverToActor), which
110 // hands the Create to the same inbox handler an HTTP POST would reach: the
111 // note is stored, the mention is stored, and a shaer:away on it is applied,
112 // all by the code that does it for everyone else. A hairpin POST to our own
113 // hostname is not that code path, it is a second one that only appears to be.
114 for (const r of resolved.filter((x) => x.local)) {
115 const res = await deliverTo(site, r.uri, create).catch(() => null);
116 if (res && res.delivered) delivered++;
117 }
118 // Remote: one POST per inbox, so two guardians on the same server share it.
119 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
120 let ok = false;
121 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
122 if (ok) delivered++;
123 else enqueueDelivery(site.slug, inbox, create);
124 }
125 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
126 return { id, content, delivered, teapots };
127}
128
129export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.