Changeset db81e56 in Klonkt


Ignore:
Timestamp:
08/24/2026 04:53:38 PM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
1976a10
Parents:
984e0c6
Message:

Opsplitsing stap 9 (shaer-drc): de inbox naar ap-inbox.js

Het hart van de ontvangst verhuist als een blok -- 840 regels,
byte-voor-byte: handleInbox, de her-verificatie van doorgestuurde
activiteiten (dereferenceForwarded, shaer-s8k) en de kas eromheen
(bekende notes, geziene notes, recente ophaal-missers). TIJDLIJN_SOORTEN
gaat mee met zijn commentaar: de inbox was zijn enige lezer nog.

De inbox is de schakelkast van de dienst. Wat al een module heeft komt
statisch binnen (transport, tijdlijn, peilingen, volgwinkel,
guardianship, ap-core); de vierendertig werktuigen die nog in de
dienstlaag wonen komen via wireInbox, en die lijst is bewust lang en
expliciet -- hij is de kaart van wat de inbox aanraakt, en elke naam die
er ooit afgaat is een cluster dat zelf verhuisd is. De §5.3-goedkeuring
blijft bij zijn guardian-broers, zoals gateOutgoingFollow bij stap 7.

Les van deze snede, nu deel van het regime: een aanroep-scan mist een
kale constante (TIJDLIJN_SOORTEN kostte twaalf rode toetsen op de eerste
proefrit); de controle is voortaan de doorsnede van ALLE identifiers in
de module met de topniveau-definities van de dienst.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 4374 regels.

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r984e0c6 rdb81e56  
    111111  voteOnPoll, voteOnRemotePoll,
    112112};
     113// Stap 9 (shaer-drc): de inbox woont in ap-inbox.js. De schakelkast krijgt
     114// onderaan zijn tweeendertig werktuigen via wireInbox.
     115import { handleInbox, wireInbox } from './ap-inbox.js';
     116export { handleInbox };
    113117// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    114118export { AP_CONTEXT, actorId, noteId, guessMediaType };
     
    234238}
    235239
    236 
    237 /**
    238  * Welke objectsoorten deze inbox in de tijdlijn opneemt.
    239  *
    240  * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
    241  * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
    242  * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
    243  * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
    244  */
    245 const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
    246240
    247241/**
     
    18071801    return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
    18081802  } catch { return null; }
    1809 }
    1810 
    1811 /**
    1812  * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
    1813  *
    1814  * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
    1815  * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
    1816  * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
    1817  * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
    1818  * binnen, zonder dat iemand een fout zag.
    1819  *
    1820  * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
    1821  * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
    1822  * bezorgde inhoud NIET en halen het object op bij de bron.
    1823  *
    1824  * Vier voorwaarden, en geen ervan is optioneel:
    1825  *
    1826  *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
    1827  *     dereferencen -- het object is weg -- dus die blijft geweigerd.
    1828  *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
    1829  *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
    1830  *     attributedTo alles kan beweren.
    1831  *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
    1832  *     levert een doorsturer een echt id met verdraaide inhoud.
    1833  *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
    1834  *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
    1835  */
    1836 /** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
    1837  *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
    1838  *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
    1839  *  doorstuurt; iets anders is dat niet. */
    1840 function knownNoteUri(uri) {
    1841   if (!uri || typeof uri !== 'string') return false;
    1842   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    1843   try {
    1844     if (base && uri.startsWith(`${base}/ap/notes/`)) {
    1845       const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
    1846       if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
    1847       if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
    1848     }
    1849     if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
    1850     if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
    1851     // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
    1852     if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
    1853   } catch { /* bij twijfel niet ophalen */ }
    1854   return false;
    1855 }
    1856 
    1857 /**
    1858  * Onthoud dat we dit bericht al eens bezorgd kregen.
    1859  *
    1860  * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
    1861  * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
    1862  * stelt voordat er iets bij de bron wordt opgehaald.
    1863  *
    1864  * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
    1865  * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
    1866  * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
    1867  * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
    1868  * schrijvers die je zelf volgt.
    1869  */
    1870 const SEEN_NOTES_DAYS = 30;
    1871 let _seenSinceSnoei = 0;
    1872 function rememberNoteUri(uri) {
    1873   if (!uri || typeof uri !== 'string') return;
    1874   try {
    1875     db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
    1876     // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
    1877     // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
    1878     // ouder is dan een maand beantwoordt geen enkele vraag meer.
    1879     if (++_seenSinceSnoei >= 500) {
    1880       _seenSinceSnoei = 0;
    1881       const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
    1882       if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
    1883     }
    1884   } catch { /* niet fataal */ }
    1885 }
    1886 const isFollowedActor = (uri) => {
    1887   try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
    1888 };
    1889 
    1890 // Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
    1891 // dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
    1892 // vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
    1893 const _derefMiss = new Map();
    1894 const DEREF_MISS_MS = 30 * 60 * 1000;
    1895 function derefRecentlyFailed(uri) {
    1896   const t = _derefMiss.get(uri);
    1897   if (t === undefined) return false;
    1898   if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
    1899   return true;
    1900 }
    1901 function noteDerefFailure(uri) {
    1902   if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
    1903     const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
    1904     for (const [k] of oud) _derefMiss.delete(k);
    1905   }
    1906   _derefMiss.set(uri, Date.now());
    1907 }
    1908 
    1909 async function dereferenceForwarded(act, claimedActor, type, slugParam) {
    1910   // Every exit states its reason. Five of the six used to return silently, so a
    1911   // rejection count could not be told apart from a narrowing that closed too far
    1912   // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
    1913   // the signer-mismatch rate (tens per hour), so this is not a noisy log.
    1914   const skipped = (reason, detail) => {
    1915     console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
    1916     return null;
    1917   };
    1918   if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
    1919   const o = act && act.object;
    1920   const objId = typeof o === 'string' ? o : (o && o.id);
    1921   if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
    1922   try {
    1923     if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
    1924   } catch { return skipped('unparsable id', objId); }
    1925   // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
    1926   // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
    1927   // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
    1928   // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
    1929   // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
    1930   // niets aan legitiem verkeer waarvan we de ouder kennen.
    1931   const parent = typeof o === 'object' && o
    1932     ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
    1933     : null;
    1934   if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
    1935   if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
    1936   // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
    1937   // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
    1938   // reden als bij fetchActor sinds efe5633.
    1939   let fetched = await apGetJson(objId).catch(() => null);
    1940   if (!fetched || fetched.id !== objId) {
    1941     // The signer used to be slugParam, which is null on the shared inbox — and
    1942     // that is where forwarded traffic lands, because we advertise a sharedInbox.
    1943     // signedGetJson falls back to an unsigned GET for a null slug, so a source in
    1944     // secure mode could never be dereferenced at all. Same fix verifyRequest got
    1945     // in shaer-afq: any local actor is a valid signer.
    1946     const asSlug = slugParam || anySigningSlug();
    1947     if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
    1948   }
    1949   const attributed = fetched && (typeof fetched.attributedTo === 'string'
    1950     ? fetched.attributedTo
    1951     : (fetched.attributedTo && fetched.attributedTo.id));
    1952   if (!fetched || fetched.id !== objId) {
    1953     noteDerefFailure(objId);
    1954     return skipped('fetch failed', objId);
    1955   }
    1956   if (attributed !== claimedActor) {
    1957     // Not a transport hiccup: the source itself says someone else wrote this.
    1958     noteDerefFailure(objId);
    1959     return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
    1960   }
    1961   return fetched;
    1962 }
    1963 
    1964 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    1965 export async function handleInbox(req, slugParam, preVerified = null) {
    1966   const act = req.body || {};
    1967   const type = act.type;
    1968   // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
    1969   // ignored inbox hits so an operator can see who is probing their fediverse inbox.
    1970   const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
    1971   const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
    1972   // preVerified is the loopback (see deliverToActor): a delivery between two
    1973   // actors on THIS instance never crosses a socket, so there is no signature to
    1974   // check — but we do know who signed, because we signed it. Handing that in
    1975   // keeps everything below identical, including the actor-versus-signer check,
    1976   // which is exactly the check that must not be skipped for being local.
    1977   const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
    1978 
    1979   // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
    1980   // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
    1981   // forged replies/likes/follows/timeline posts). GET/discovery stays open.
    1982   const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    1983   // Blocked actor/domain → silently drop (202, don't reveal the block).
    1984   if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
    1985   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    1986   if (GATED.includes(type)) {
    1987     // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
    1988     // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
    1989     // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
    1990     let forwarded = null;
    1991     if (verified && claimedActor && verified.id !== claimedActor) {
    1992       forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
    1993       if (forwarded) {
    1994         act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
    1995         console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
    1996       }
    1997     }
    1998     if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
    1999       // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
    2000       // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
    2001       // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
    2002       // of geldig ondertekend door iemand anders.
    2003       const reden = verified ? '(signer mismatch)'
    2004         : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
    2005         : '(no signature)';
    2006       console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
    2007       return 401;
    2008     }
    2009     // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
    2010     // from an actor that guards someone here restores it to active for those
    2011     // wards and cancels any lapse running against it, before the activity is
    2012     // even looked at. Signature-gated on purpose: an unverified claim of
    2013     // being gran must not wake gran up.
    2014     try {
    2015       const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
    2016       if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
    2017       for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
    2018     } catch { /* availability is never load-bearing for delivery */ }
    2019   }
    2020 
    2021   // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
    2022   // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
    2023   if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
    2024     if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
    2025   }
    2026 
    2027   // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
    2028   // Accept/Reject answers an offer a local guardian sent. Anything the
    2029   // guardianship module does not recognize falls through to the old paths.
    2030   // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
    2031   // must be seen BEFORE the generic Undo branch below, which only knows about
    2032   // Follow/Like/Announce and would swallow it with a 202.
    2033   if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
    2034     // Every LOCAL party this activity is addressed to gets its own copy of the
    2035     // handshake (a ward and a co-guardian may both live here). Gather candidate
    2036     // local slugs from the inbox owner, the `to` list, and the ward.
    2037     // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
    2038     // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
    2039     // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
    2040     // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
    2041     // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
    2042     // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
    2043     // eigen basis begint en dat de site echt bestaat.
    2044     const cand = new Set();
    2045     if (slugParam) cand.add(slugParam);
    2046     for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
    2047       if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
    2048     }
    2049     if (type === 'Offer' || type === 'Undo') {
    2050       const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
    2051       if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
    2052     }
    2053     let consumed = false;
    2054     for (const slug of cand) {
    2055       const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
    2056       if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
    2057     }
    2058     if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
    2059   }
    2060 
    2061   // A moderation report (Flag) about our content — store it for the targeted site's owner
    2062   // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
    2063   if (type === 'Flag') {
    2064     const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
    2065     const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
    2066     let targetSlug = null;
    2067     const noteIds = [];
    2068     for (const u of objectUris) {
    2069       const s = localSlugOf(u);             // one of OURS -- host meegewogen
    2070       if (s) { targetSlug = targetSlug || s; continue; }
    2071       const pid = postIdFromNoteUrl(u, base); // one of our notes?
    2072       if (pid) noteIds.push(pid);
    2073     }
    2074     if (!targetSlug && noteIds.length) {
    2075       try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
    2076     }
    2077     if (!targetSlug) return 202; // not about us / can't tell → drop
    2078     // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
    2079     const ai = actorInfo(verified || null, claimedActor);
    2080     try {
    2081       db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
    2082         .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
    2083       console.log('[AP] report received for', targetSlug, 'from', claimedActor);
    2084     } catch { /* ignore */ }
    2085     return 202;
    2086   }
    2087 
    2088   // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
    2089   // a Move often arrives seconds before the new actor's re-Follow wave, and the
    2090   // swap below must not race our own outgoing Follow of the target.
    2091   if (type === 'Move') {
    2092     return handleMoveInbox(act, { verifiedActor: claimedActor });
    2093   }
    2094 
    2095   if (type === 'Follow') {
    2096     const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    2097     // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
    2098     //
    2099     // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
    2100     // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
    2101     // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
    2102     // ongeluk meenemen.
    2103     //
    2104     // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
    2105     // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
    2106     const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
    2107     if (who && libSlug) {
    2108       const remote = await fetchActor(who);
    2109       if (!remote || !remote.inbox) return 202;
    2110       const fi = actorInfo(remote, who);
    2111       luisteraars.voegToe(libSlug, {
    2112         actorUri: who, inbox: remote.inbox,
    2113         sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
    2114         name: fi.name, handle: fi.handle, icon: fi.icon,
    2115       });
    2116       const keys = getOrCreateKeys(libSlug);
    2117       const accept = {
    2118         '@context': AP_CONTEXT,
    2119         id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
    2120         type: 'Accept', actor: actorId(base, libSlug), object: act,
    2121       };
    2122       deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
    2123         .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
    2124       console.log('[AP] library follow from', who, '->', libSlug);
    2125       return 202;
    2126     }
    2127     // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
    2128     // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
    2129     // kon een Follow op andermans actor met dezelfde padstaart hier een volger
    2130     // opleveren.
    2131     const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
    2132     if (!who || !slug) return 400;
    2133     const remote = await fetchActor(who);
    2134     if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
    2135     const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
    2136     const fi = actorInfo(remote, who);   // cache display for the friends list (shaer-aa3)
    2137     // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
    2138     // follow is gated. A committed guardian's own Follow is auto-accepted
    2139     // (it needs no gate); anyone else is held pending for guardian approval.
    2140     // Free actors / normal sites have no guardians → fall through, unchanged.
    2141     const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
    2142     if (wardGuardians.length && !wardGuardians.includes(who)) {
    2143       const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
    2144       Guardianship.follows.recordPending(slug, {
    2145         id: followId, follower: who, inbox: remote.inbox, sharedInbox,
    2146         name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
    2147       });
    2148       // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
    2149       // gated follow to its guardians for approval. A LOCAL guardian gets a
    2150       // push and reads /guardian directly; a REMOTE guardian gets an
    2151       // Offer(Follow) delivered so its instance stores a copy (same distributed
    2152       // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
    2153       const wardActor = actorId(base, slug);
    2154       const wardKeys = getOrCreateKeys(slug);
    2155       const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
    2156       // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
    2157       // every guardian. The ONLY admissible evidence is a request like this
    2158       // one going unanswered; recordRequest itself skips a declared absence.
    2159       for (const g of wardGuardians) {
    2160         try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
    2161       }
    2162       for (const g of wardGuardians) {
    2163         // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
    2164         // ignores the host (an /ap/users/x path on a remote host is someone
    2165         // else's actor), so also require our base + an existing local site.
    2166         const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
    2167         const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
    2168         if (isLocal) {
    2169           const L = pushLang(gslug);
    2170           // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
    2171           // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
    2172           // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
    2173           // er vraagt, en om wie het gaat (shaer-p729).
    2174           pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folin_t'), body: i18nT(L, 'push.n_guard_folin_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
    2175         } else {
    2176           fetchActor(g).then((ga) => {
    2177             const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
    2178             if (!inbox) return;
    2179             const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
    2180             const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:decisive': beslissend2 };
    2181             deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
    2182           }).catch(() => {});
    2183         }
    2184       }
    2185       console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
    2186       return 202;
    2187     }
    2188     // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
    2189     // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
    2190     // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
    2191     // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
    2192     // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
    2193     // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
    2194     const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
    2195     if (ownerGate && ownerGate.approve_followers) {
    2196       const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
    2197       Guardianship.follows.recordPending(slug, {
    2198         id: followId, follower: who, inbox: remote.inbox, sharedInbox,
    2199         name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
    2200       });
    2201       const L = pushLang(slug);
    2202       pushEvent(slug, {
    2203         type: 'follow',
    2204         title: i18nT(L, 'push.n_folreq_t'),
    2205         body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
    2206         url: `${pushPrefix(slug)}/connect`,
    2207       });
    2208       console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
    2209       return 202;
    2210     }
    2211     fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
    2212     try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
    2213     { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
    2214     const me = actorId(base, slug);
    2215     const keys = getOrCreateKeys(slug);
    2216     const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
    2217     deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
    2218     // Auto-backfill: send our recent posts as Create so the instance has our history
    2219     // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
    2220     // Mastodon dedupes notes per-instance, so re-filling an instance that already has
    2221     // a follower of ours is wasted work (and won't re-populate the new follower's
    2222     // timeline anyway). Deliver to the shared inbox (instance-level) when present.
    2223     // Sync insert+check (no await between) → no interleave race with concurrent Follows.
    2224     const instanceFilled = sharedInbox &&
    2225       db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
    2226         .get(slug, sharedInbox, who);
    2227     if (!instanceFilled) {
    2228       backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
    2229     }
    2230     console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
    2231     return 202;
    2232   }
    2233   // Een luisteraar die weggaat, hoort meteen weg te zijn.
    2234   if (type === 'Undo' && act.object && act.object.type === 'Follow') {
    2235     const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
    2236     const libSlug = libraryOwnerSlug(doel);
    2237     const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    2238     if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
    2239       console.log('[AP] library unfollow from', wie, '->', libSlug);
    2240       return 202;
    2241     }
    2242   }
    2243 
    2244   if (type === 'Undo' && act.object) {
    2245     const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    2246     const ot = act.object.type;
    2247     if (ot === 'Follow') {
    2248       const obj = act.object.object;
    2249       const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
    2250       if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
    2251       return 202;
    2252     }
    2253     if (ot === 'Like' || ot === 'Announce') {
    2254       const tgt = act.object.object;
    2255       const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
    2256       if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
    2257       return 202;
    2258     }
    2259     return 202;
    2260   }
    2261 
    2262   const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    2263   const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
    2264   // Our OWN activity is already stored via ap_outbox: don't store it twice.
    2265   // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
    2266   // machine". The old reading dropped every activity between two sites on one
    2267   // instance, so a note from a co-located guardian to its ward was accepted
    2268   // with a 202 and then quietly thrown away: no mention, no away, no help
    2269   // request. Neighbours are not us (Robins regel, 29-7: on this machine
    2270   // everything behaves as if every Klonkt were somewhere else).
    2271   const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
    2272 
    2273   // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
    2274   if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
    2275     const o = act.object;
    2276     // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
    2277     // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
    2278     // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
    2279     if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
    2280       const seg = postIdFromNoteUrl(o.inReplyTo, base);
    2281       if (seg && localPostExists(seg)) {
    2282         const rec = recordPollBallot(seg, actorUri, o.name);
    2283         if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
    2284       }
    2285     }
    2286     const tgt = findThreadTarget(o.inReplyTo, base);
    2287     if (tgt && actorUri && !isLocalActor) {
    2288       const ai = actorInfo(await resolveActor(actorUri), actorUri);
    2289       const html = HtmlSanitizerService.sanitize(o.content || '');
    2290       if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
    2291       iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
    2292       console.log('[AP] reply', actorUri, '→', tgt.post_id);
    2293       // A reply is a post too: Berichten renders it the way de Krant renders a
    2294       // timeline row, so it needs the same media and the same quote/preview card.
    2295       {
    2296         const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
    2297         const key = ['reply', tgt.post_id, actorUri, o.id || ''];
    2298         const mj = mediaFromNote(o);
    2299         if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
    2300         resolveCard(o).then((c) => {
    2301           if (!c) return;
    2302           const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
    2303           try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
    2304         }).catch(() => { /* best-effort */ });
    2305       }
    2306       {
    2307         // Private (followers/direct) replies push as a DM ping WITHOUT content
    2308         // (the push service should never carry private text, design decision);
    2309         // public replies carry a short snippet.
    2310         const ctx = pushPostCtx(tgt.post_id);
    2311         const vis = noteVisibility(o);
    2312         const priv = vis === 'direct' || vis === 'followers';
    2313         if (ctx) {
    2314           const L = pushLang(ctx.site);
    2315           const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
    2316           if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
    2317           else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
    2318         }
    2319       }
    2320       return 202;
    2321     }
    2322     // Home timeline (client): a top-level post from an account we follow.
    2323     if (actorUri && !isLocalActor && belongsInTimeline(o)) {
    2324       let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
    2325       if (subs.length) {
    2326         const ai = actorInfo(await resolveActor(actorUri), actorUri);
    2327         const { html, atts: _atts, url: _url } = timelineFields(o);
    2328         const media = JSON.stringify(_atts);
    2329         const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
    2330         // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
    2331         // incoming posts to the fediverse — that flooded followers. Boosting to the
    2332         // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
    2333         // the timeline).
    2334         for (const s of subs) {
    2335           tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
    2336           // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
    2337           if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
    2338           // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
    2339           { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
    2340           storeAuthorEmoji(o.id, s.slug, ai);   // custom-emoji display name for the byline
    2341 
    2342           // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
    2343           { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
    2344           if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
    2345         }
    2346         // FEP-044f embedded quote card: resolve the quoted post out of band so
    2347         // the inbox response is not blocked on a remote fetch. Best-effort.
    2348         if (quoteHrefOf(o)) {
    2349           const slugs = subs.map((s) => s.slug);
    2350           resolveQuote(o).then((qj) => {
    2351             if (!qj) return;
    2352             for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
    2353           }).catch(() => { /* best-effort */ });
    2354         } else {
    2355           // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
    2356           // thumbnail-only. Also out of band, and stored for everyone; the gate
    2357           // that decides who may SEE it is applied at serve time (§5.3-style
    2358           // gated feature, see the inbox read).
    2359           const slugs = subs.map((s) => s.slug);
    2360           resolveExternalEmbed(o.content).then((ej) => {
    2361             if (!ej) return;
    2362             for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
    2363           }).catch(() => { /* best-effort */ });
    2364         }
    2365         console.log('[AP] timeline +', actorUri, 'x' + subs.length);
    2366       }
    2367     }
    2368     // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
    2369     // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
    2370     // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
    2371     // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
    2372     // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
    2373     // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
    2374     // moet iemand zijn die je zelf bent gaan volgen.
    2375     if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
    2376       rememberNoteUri(o.id);
    2377     }
    2378     // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
    2379     // above): store a mention notification for each of our actors named in the Mention tags.
    2380     // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
    2381     // someone else's actor, not ours.
    2382     // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
    2383     // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
    2384     // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
    2385     // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
    2386     // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
    2387     if (actorUri && !isLocalActor) {
    2388       const mark = Guardianship.help.parseMarker(o);
    2389       if (mark) {
    2390         const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
    2391         Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
    2392         wakeGuardian(slug);   // een mede-guardian pakte iets op: het paneel hoort het meteen
    2393         console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
    2394       }
    2395     }
    2396     if (actorUri && !isLocalActor && o.id) {
    2397       const slugs = localMentionSlugs(o.tag, base);
    2398       if (slugs.length) {
    2399         const ai = actorInfo(await resolveActor(actorUri), actorUri);
    2400         const html = HtmlSanitizerService.sanitize(o.content || '');
    2401         // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
    2402         // flag is stored so the Guardian PWA's message centre can list it.
    2403         const help = Guardianship.isHelpRequest(o);
    2404         const wave = Guardianship.isWave(o);
    2405         const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
    2406         // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
    2407         // same direct note the mention below stores (so the kid also reads it
    2408         // as an ordinary message). Recorded only from an actual guardian of
    2409         // the addressed ward, and only with an end: an absence without an end
    2410         // is logged and dropped, never guessed.
    2411         if (Guardianship.availability.isAway(o)) {
    2412           const until = Guardianship.availability.parseEndTime(o.endTime);
    2413           for (const slug of slugs) {
    2414             const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
    2415             if (!isG) continue;
    2416             if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
    2417             Guardianship.availability.declareAway(slug, actorUri, until);
    2418             console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
    2419           }
    2420         }
    2421         // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de
    2422         // afwezigheidsmelding: een gewone directe note met een shaer:-markering,
    2423         // per genoemde ontvanger afgehandeld.
    2424         //
    2425         // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag
    2426         // maar een onbekende die iets over jouw instellingen wil zeggen -- dat
    2427         // hoort in geen enkele lijst te belanden waar een guardian op afgaat.
    2428         {
    2429           const req = Guardianship.gatereq.parseRequest(o);
    2430           if (req) {
    2431             for (const slug of slugs) {
    2432               const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();
    2433               if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }
    2434               Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);
    2435               wakeGuardian(slug);   // het kind vroeg om een poort
    2436               console.log('[AP] gate request', req.feature, actorUri, '→', slug);
    2437             }
    2438           }
    2439         }
    2440         for (const slug of slugs) {
    2441           try {
    2442             const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
    2443                                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
    2444               .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
    2445                 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
    2446             if (r.changes) {
    2447               // The quote / link-preview card resolves out of band (a remote
    2448               // fetch), exactly as it does for a timeline post, so the inbox
    2449               // answer is never blocked on it.
    2450               resolveCard(o).then((c) => {
    2451                 if (!c) return;
    2452                 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
    2453                 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
    2454               }).catch(() => { /* best-effort */ });
    2455               console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
    2456               const vis = noteVisibility(o);
    2457               const priv = vis === 'direct' || vis === 'followers';
    2458               const L = pushLang(slug);
    2459               const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
    2460               // Same privacy rule as replies: private mentions push without content.
    2461               // A help request pushes as its own alert type, aimed at the
    2462               // Guardian PWA's message centre.
    2463               if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
    2464               else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
    2465               else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
    2466             }
    2467           } catch { /* ignore */ }
    2468         }
    2469       }
    2470     }
    2471     return 202;
    2472   }
    2473   // A remote post we cached was edited upstream → refresh our cached copy. This is the
    2474   // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
    2475   // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
    2476   // edit A's note (the signature gate guarantees claimedActor == the verified signer).
    2477   if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    2478     const o = act.object;
    2479     if (o.id && claimedActor) {
    2480       const html = HtmlSanitizerService.sanitize(o.content || '');
    2481       const media = mediaFromNote(o);
    2482       try {
    2483         // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
    2484         // rename keeps the same AP id but changes the human url, so without this the cached
    2485         // post would keep linking to the old, now-dead URL.
    2486         const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
    2487           .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
    2488         if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
    2489         // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
    2490         // site keeps its own `voted` state while the counts/closed update to the new totals.
    2491         const poll = parsePoll(o);
    2492         if (poll) {
    2493           const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
    2494           const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
    2495           for (const rw of rows) {
    2496             let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
    2497             upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
    2498           }
    2499         }
    2500       } catch { /* ignore */ }
    2501       // If this note is a cached fediverse reply on one of our posts, refresh its text too.
    2502       try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
    2503     }
    2504     return 202;
    2505   }
    2506   if (type === 'Like' || type === 'Announce') {
    2507     const tgt = act.object;
    2508     const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
    2509     const pid = postIdFromNoteUrl(objUrl, base);
    2510     if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
    2511       // A boost/like of a non-public post is dropped, not stored: nobody
    2512       // outside the audience should even hold it (shaer-tqc hardening).
    2513       const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
    2514       if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
    2515         console.log('[AP] dropped', type, 'on non-public post', pid);
    2516         return;
    2517       }
    2518       const ai = actorInfo(await resolveActor(actorUri), actorUri);
    2519       iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
    2520       console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
    2521       {
    2522         const ctx = pushPostCtx(pid);
    2523         if (ctx) {
    2524           const L = pushLang(ctx.site);
    2525           const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
    2526           if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
    2527           else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
    2528         }
    2529       }
    2530     } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
    2531       // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
    2532       // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
    2533       // re-announcing an incoming Announce would cascade boosts across the network).
    2534       let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
    2535       if (subs.length) {
    2536         const bn = await fetchNoteAP(objUrl);
    2537         if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
    2538           const origUri = actorUriOf(bn.attributedTo);
    2539           // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
    2540           // author is blocked — otherwise a block is bypassed via someone else's boost.
    2541           if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
    2542           const oai = actorInfo(await resolveActor(origUri), origUri);
    2543           const html = HtmlSanitizerService.sanitize(bn.content || '');
    2544           const media = mediaFromNote(bn);
    2545           const booster = actorInfo(await resolveActor(actorUri), actorUri);
    2546           for (const s of subs) {
    2547             // published = now → the boost shows as fresh activity at the top (Mastodon shows
    2548             // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
    2549             // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
    2550             let inserted = false;
    2551             try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
    2552             if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
    2553             storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
    2554             // A boost carries the same renderable tags as a Create: capture the
    2555             // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
    2556             // 044f) so boosted posts render like any other, not as raw shortcodes.
    2557             { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }
    2558             { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }
    2559           }
    2560           // FEP-044f: resolve the embedded quote card for a boosted post too
    2561           // (out of band, best-effort, so it does not block the inbox response).
    2562           if (quoteHrefOf(bn)) {
    2563             const slugs = subs.map((s) => s.slug);
    2564             resolveQuote(bn).then((qj) => {
    2565               if (!qj) return;
    2566               for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }
    2567             }).catch(() => { /* best-effort */ });
    2568           }
    2569           console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
    2570         }
    2571       }
    2572     }
    2573     return 202;
    2574   }
    2575   if (type === 'Delete') {
    2576     // A remote note was deleted upstream → drop it from replies AND the timeline.
    2577     // Scope to the SIGNING actor so actor B can't delete actor A's content (the
    2578     // signature gate guarantees claimedActor == the verified signer here).
    2579     const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
    2580     if (oid && claimedActor) {
    2581       try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
    2582       try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
    2583       // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
    2584       // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
    2585       // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
    2586       // to A's posts).
    2587       try {
    2588         let sameHost = false;
    2589         try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
    2590         if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
    2591       } catch { /* ignore */ }
    2592     }
    2593     return 202;
    2594   }
    2595   // Accept/Reject of a Follow WE sent (client side).
    2596   if (type === 'Accept' && act.object) {
    2597     const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
    2598     let raak = 0;
    2599     if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
    2600     // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
    2601     // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
    2602     // EIGEN id voor, in ONZE namespace:
    2603     //
    2604     //   wij stuurden   .../ap/users/dev#follow-1786161977286-bb2de32f
    2605     //   Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
    2606     //
    2607     // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
    2608     // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
    2609     // die pas opviel toen er nooit iets binnenkwam.
    2610     //
    2611     // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
    2612     // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
    2613     // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
    2614     // openzetten dan een follow die wij zelf hebben verstuurd.
    2615     //
    2616     // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
    2617     // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
    2618     // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
    2619     // onze eigen actor-URI.
    2620     let mij = slugParam;
    2621     if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
    2622     if (!raak && mij && actorUri) {
    2623       try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
    2624     }
    2625     // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
    2626     // zien te zijn in plaats van als succes voorbij te komen.
    2627     console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
    2628     // The moment a friendship exists is the moment the history comes along
    2629     // (Robins besluit, 30-7): delivery cannot reach into the past, so the
    2630     // fresh follower pulls the outbox, signed, and the other side now serves
    2631     // the friends-only posts too.
    2632     if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
    2633     return 202;
    2634   }
    2635   if (type === 'Reject' && act.object) {
    2636     const who = actorUri;
    2637     if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
    2638     return 202;
    2639   }
    2640 
    2641   // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
    2642   // tussen een soort die we bewust overslaan en een die we niet kennen -- en
    2643   // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
    2644   // waar deze inbox alleen Note, Article en Question aanneemt.
    2645   const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
    2646   console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
    2647     'from', ip, 'by', claimedActor || '?', '(ignored)');
    2648   return 202;
    26491803}
    26501804
     
    51754329// telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
    51764330wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
     4331// De schakelkast (stap 9): de lijst is bewust lang -- hij is de kaart van wat
     4332// de inbox aanraakt, en elke naam die eraf gaat is een cluster dat zelf
     4333// verhuisd is.
     4334wireInbox({
     4335  actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
     4336  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
     4337  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
     4338  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
     4339  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
     4340  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
     4341  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
     4342  storeAuthorEmoji, timelineFields, wakeGuardian,
     4343});
    51774344
    51784345export default {
Note: See TracChangeset for help on using the changeset viewer.