source: Klonkt/src/services/guardianship/delivery.js@ 0ca7e9a4

main
Last change on this file since 0ca7e9a4 was 6d5ce0c, checked in by Robin <roboburr@…>, 6 weeks ago

Op deze machine gedraagt elke Klonkt zich alsof hij ergens anders staat

Robins regel, en de reden staat in de logs van deze week. Twee bugs kwamen uit
hetzelfde patroon: een tweede, lokale route die een kapotte externe route
verborg. De Undo bereikte een kind op dezelfde machine nooit, en het gated
voorstel was een maand stuk over de lijn terwijl de sluiproute de stem hier
direct opschreef en het dashboard er prima uitzag.

Samenlokatie is nu een kwestie van TRANSPORT, geen beslispad. deliverToActor
geeft een activiteit voor een lokale ontvanger door aan dezelfde inbox-handler
die de lijn zou bereiken, inclusief de controle of de ondertekenaar de afzender
is. Alles daarboven weet het verschil niet meer, en dus draait elke deployment
dezelfde code.

Directe berichten deden dat nog niet. Die zochten een inbox op en POSTten
erheen, dus een bericht aan een kind op deze machine ging naar onze eigen
hostnaam en terug, of nergens heen. Nu nemen ze dezelfde loopback.

En de kern van het probleem zat in de inbox zelf: "van onze eigen actor" werd
gelezen als "van wie dan ook op deze machine". Daardoor werd elk bericht tussen
twee sites op een instantie met een 202 aangenomen en daarna weggegooid: geen
vermelding, geen afwezigheid, geen hulpvraag. Buren zijn niet wij.

Daarmee konden twee met de hand geschreven sluiproutes weg: het lokaal
wegschrijven van een afwezigheid in de C2S-outbox en in de Guardian PWA. Die
bestonden alleen omdat de echte weg niet aankwam.

Changed files:
src/services/ActivityPubService.js

  • isLocalActor is nu "de eigenaar van deze inbox", niet "iemand op deze host"
  • localActor(): het actordocument van een site die wij hosten, uit onze eigen database in plaats van via een verzoek aan onszelf
  • de lokale sluiproute voor afwezigheid in de C2S-outbox is weg

src/services/guardianship/delivery.js

  • een lokale ontvanger krijgt het bericht via de loopback, de rest per inbox
  • een lokale ontvanger wordt lokaal opgezocht, dus hij valt niet stilletjes uit de ontvangerslijst als het verzoek aan onszelf mislukt

src/routes/guardian.js

  • /api/away schrijft niets meer zelf weg: het bericht doet het werk

src/services/guardianship/handshake.js

  • commentaar bijgewerkt bij de plekken die wel lokaal mogen schrijven

New file:
test/co-location.test.js

  • hetzelfde scenario twee keer, alles-lokaal en alles-extern, met de eis dat de eindtoestand gelijk is
  • een afwezigheid via de loopback en dezelfde brief van de lijn gelezen
  • de loopback weigert nog steeds een afzender die niet klopt
  • een bewaking die faalt zodra er een nieuwe lokale sluiproute in een beslispad verschijnt, met de legitieme uitzonderingen bij naam

remarks: 329 tests groen, en de server start. Twee dingen om te weten voor de
uitrol: sites op een instantie die elkaar volgen zien elkaars berichten nu wel
(dat is wat volgen betekent, maar het is zichtbaar anders), en gated follows
lopen voor een lokale guardian nog steeds via de gedeelde database. Die laatste
staat met naam en toenaam in de bewakingstest, zodat hij niet vergeten wordt.

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

  • Property mode set to 100644
File size: 6.3 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';
15
16const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
17
18let deps = null;
19/** Called once by ActivityPubService with the shared AP helpers. */
20export function wireDelivery(d) { deps = d; }
21
22// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
23// safest bucket they match.
24export function c2sVisibility(object) {
25 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
26 const to = arr(object.to), cc = arr(object.cc);
27 const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
28 const isFollowers = (x) => /\/followers\/?$/.test(x);
29 if (to.some(isPublic)) return 'public';
30 if (cc.some(isPublic)) return 'quiet';
31 if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
32 if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior
33 return 'direct';
34}
35
36// A direct note: a NEW conversation (or a direct reply) addressed to specific
37// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
38// list, delivered to exactly those inboxes: no followers fan-out, no Public,
39// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
40// guardian on any instance receives it as a private mention (the ward
41// call-for-help path).
42export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
43 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
44 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
45 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
46 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
47 if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
48 const me = actorId(base, site.slug);
49 // Resolve every recipient for a mention anchor + a delivery inbox.
50 const resolved = [];
51 for (const uri of list) {
52 // An actor we host is read from our own database, not fetched from our own
53 // hostname: that request has to leave the machine and come back, and when
54 // it does not, the recipient is silently dropped from the note. Everything
55 // that decides anything still runs below, for local and remote alike.
56 const a = (localActor && localActor(uri)) || await fetchActor(uri).catch(() => null);
57 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
58 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
59 }
60 if (!resolved.length) return null;
61 const mention = resolved.map((r) => {
62 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
63 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
64 }).join('');
65 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
66 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
67 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
68 // Attachments: same rules as deliverReply (own /media/ uploads only,
69 // image/audio/video, max 4) — the help-buoy capture rides this.
70 const media = (Array.isArray(attachments) ? attachments : [])
71 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
72 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
73 .slice(0, 4)
74 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
75 const id = crypto.randomUUID();
76 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)
77 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
78 .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);
79 const row = getOutboxRow(id);
80 const note = buildReplyNote(base, site, row);
81 const create = {
82 '@context': AP_CONTEXT,
83 id: note.id + '#create', type: 'Create', actor: me,
84 published: note.published, to: note.to, cc: note.cc, object: note,
85 };
86 const keys = getOrCreateKeys(site.slug);
87 const keyId = `${me}#main-key`;
88 let delivered = 0;
89 // A recipient on this machine takes the loopback (deliverToActor), which
90 // hands the Create to the same inbox handler an HTTP POST would reach: the
91 // note is stored, the mention is stored, and a shaer:away on it is applied,
92 // all by the code that does it for everyone else. A hairpin POST to our own
93 // hostname is not that code path, it is a second one that only appears to be.
94 for (const r of resolved.filter((x) => x.local)) {
95 const res = await deliverTo(site, r.uri, create).catch(() => null);
96 if (res && res.delivered) delivered++;
97 }
98 // Remote: one POST per inbox, so two guardians on the same server share it.
99 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
100 let ok = false;
101 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
102 if (ok) delivered++;
103 else enqueueDelivery(site.slug, inbox, create);
104 }
105 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
106 return { id, content, delivered };
107}
108
109export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.