Ignore:
Timestamp:
07/29/2026 10:50:32 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
08ab8ad
Parents:
c8e03c6
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rc8e03c6 r6d5ce0c  
    13451345
    13461346// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    1347 export async function handleInbox(req, slugParam) {
     1347export async function handleInbox(req, slugParam, preVerified = null) {
    13481348  const act = req.body || {};
    13491349  const type = act.type;
     
    13521352  const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
    13531353  const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
    1354   const verified = await verifyRequest(req).catch(() => null);
     1354  // preVerified is the loopback (see deliverToActor): a delivery between two
     1355  // actors on THIS instance never crosses a socket, so there is no signature to
     1356  // check — but we do know who signed, because we signed it. Handing that in
     1357  // keeps everything below identical, including the actor-versus-signer check,
     1358  // which is exactly the check that must not be skipped for being local.
     1359  const verified = preVerified || await verifyRequest(req).catch(() => null);
    13551360
    13561361  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
     
    15341539  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    15351540  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
    1536   // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
    1537   const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
     1541  // Our OWN activity is already stored via ap_outbox: don't store it twice.
     1542  // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
     1543  // machine". The old reading dropped every activity between two sites on one
     1544  // instance, so a note from a co-located guardian to its ward was accepted
     1545  // with a 202 and then quietly thrown away: no mention, no away, no help
     1546  // request. Neighbours are not us (Robins regel, 29-7: on this machine
     1547  // everything behaves as if every Klonkt were somewhere else).
     1548  const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
    15381549
    15391550  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
     
    22532264            awayUntil = Guardianship.availability.parseEndTime(object.endTime);
    22542265            if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
    2255             // A ward we host ourselves never receives its own delivery
    2256             // (private ranges, loopback): apply locally, the way the
    2257             // handshake commit does.
    2258             const meUri = selfActorId(site.slug);
    2259             for (const uri of recipients) {
    2260               const wslug = uri.startsWith(`${base}/`) ? slugFromActorUrl(uri) : null;
    2261               if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === meUri)) {
    2262                 Guardianship.availability.declareAway(wslug, meUri, awayUntil);
    2263               }
    2264             }
     2266            // No local shortcut here: the note below reaches a ward on this
     2267            // instance through the loopback, and its inbox handler applies the
     2268            // absence like it does for a ward anywhere else. One path.
    22652269          }
    22662270          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil });
     
    38073811  const keys = getOrCreateKeys(site.slug);
    38083812  const payload = { '@context': AP_CONTEXT, ...activity };
     3813  // Co-location is a TRANSPORT detail, never a decision path (Robins regel,
     3814  // 29-7). An inbox on this machine is not reachable over HTTP from this
     3815  // machine, and should not be, so a local recipient is handed the activity
     3816  // straight into the same inbox handler the wire would reach. Everything
     3817  // above this line therefore behaves as if every Klonkt were remote: one code
     3818  // path, exercised by every deployment, including the checks. Two bugs in one
     3819  // day came from having a second, local-only path that hid a broken remote
     3820  // one.
     3821  const localSlug = localSlugOf(actorUri);
     3822  if (localSlug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(localSlug)) {
     3823    const host = (() => { try { return new URL(selfActorId(site.slug)).host; } catch { return ''; } })();
     3824    const req = { body: payload, ip: 'loopback', protocol: 'https', get: () => host, headers: {} };
     3825    // The signer is us, and we say so: the actor-versus-signer check runs
     3826    // exactly as it does over the wire, so a mismatch fails here too.
     3827    const status = await handleInbox(req, localSlug, { id: me }).catch(() => 500);
     3828    const ok = status >= 200 && status < 300;
     3829    console.log('[AP]', activity.type, ok ? 'delivered (loopback) →' : `got ${status} (loopback) from`, actorUri);
     3830    return { delivered: ok, inbox: `${actorUri}/inbox`, loopback: true, status };
     3831  }
    38093832  const a = await fetchActor(actorUri).catch(() => null);
    38103833  const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
     
    38223845}
    38233846Guardianship.wireDelivery({
    3824   actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
     3847  actorId, fetchActor, localActor, deliverTo: deliverToActor, deriveHandle, escHtml, linkUrls, linkHashtags,
    38253848  getOutboxRow: (id) => iStmts().getO.get(id),
    38263849  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
    38273850});
     3851/**
     3852 * The actor document of a site WE host, read straight from the database.
     3853 * Same shape fetchActor returns for anyone else, plus `local: true` so the
     3854 * caller can take the loopback instead of a POST to our own hostname.
     3855 * Null for an actor we do not host: that one really is fetched.
     3856 */
     3857function localActor(actorUri) {
     3858  const slug = localSlugOf(actorUri);
     3859  if (!slug) return null;
     3860  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3861  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
     3862  if (!site) return null;
     3863  // primary_slug is what buildActor uses to pick '/' over '/user/<slug>'; the
     3864  // actor route sets it the same way before building.
     3865  const p = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get();
     3866  try { return { ...buildActor(base, { ...site, primary_slug: p && p.slug }), local: true }; } catch { return null; }
     3867}
    38283868// Which local site (if any) hosts this actor URI — used by the handshake to
    38293869// apply the local side of a commit and to derive a ward's existing guardians.
Note: See TracChangeset for help on using the changeset viewer.