Changeset 6d5ce0c in Klonkt for src


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

Location:
src
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/guardian.js

    rc8e03c6 r6d5ce0c  
    309309//    handshake module decides when it commits (§3.1).
    310310// ── Step away (FEP-633c 3.6.1): the guardian declares itself unavailable ──
    311 // One direct note with shaer:away and an endTime to every ward, the same
    312 // path Shaer takes over C2S. Wards on this instance are applied directly (a
    313 // local inbox never receives its own delivery); the rest travels S2S.
     311// One direct note with shaer:away and an endTime to every ward, the same path
     312// Shaer takes over C2S, and the only path: a ward on this instance receives
     313// that note through the loopback and applies the absence in its own inbox
     314// handler, exactly as a ward elsewhere does. This route used to write the
     315// local wards itself as well, which meant the wire version could break without
     316// anyone here noticing.
    314317router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
    315318  const site = siteForUser(req);
     
    320323  if (!wards.length) return res.status(409).json({ error: 'no_wards' });
    321324  const until = Date.now() + days * 24 * 3600 * 1000;
    322   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    323   const me = AP.actorId(base, site.slug);
    324   let applied = 0;
    325   for (const uri of wards) {
    326     const wslug = uri.startsWith(`${base}/`) ? uri.replace(/\/+$/, '').split('/').pop() : null;
    327     if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === me)) {
    328       Guardianship.availability.declareAway(wslug, me, until);
    329       applied++;
    330     }
    331   }
    332325  const L = resolveLang(req);
    333326  const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
    334327  const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
    335   if (!applied && !(r && r.id)) return res.status(502).json({ error: 'away_failed' });
     328  if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
    336329  res.json({ ok: true, until });
    337330});
     
    508501  const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
    509502  const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
     503  // ONE path, whether the ward lives here or on the other side of the world
     504  // (Robins regel, 29-7): propose over the wire and let the ward's server do
     505  // what it does for everyone. deliverToActor loops a local recipient back
     506  // into the same inbox handler, so co-location changes the transport and
     507  // nothing else. The old shortcut recorded the vote here directly, which is
     508  // how the remote path stayed broken for a month without anyone noticing.
     509  AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
    510510  const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
    511   const localWard = localSlug ? db.prepare('SELECT slug FROM sites WHERE slug = ?').get(localSlug) : null;
    512   if (localWard) {
    513     Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow);
    514     const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow);
    515     // Same forward as the S2S path: without it the other guardians never learn
    516     // the proposal exists and a threshold of two can never be met.
    517     if (r.state === 'open') {
    518       const wardActor = AP.actorId(base, localWard.slug);
    519       for (const g of Guardianship.listGuardians(localWard.slug).map((x) => x.other_uri)) {
    520         if (g === me) continue;
    521         // Signed by the ward, so the body must say the ward: anything else is
    522         // a signer mismatch and the receiver answers 401 (as it should).
    523         AP.deliverToActor(
    524           db.prepare('SELECT * FROM sites WHERE slug = ?').get(localWard.slug),
    525           g,
    526           { ...offer, actor: wardActor, to: [g], 'shaer:proposer': me },
    527         ).catch(() => { /* queued */ });
    528       }
    529     }
    530     return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of });
    531   }
    532   AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
    533   res.json({ ok: true, allow, state: 'open', federated: true });
     511  const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
     512  res.json({ ok: true, allow, state: 'open', ...(progress || { federated: true }) });
    534513}
    535514
  • 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.
  • src/services/guardianship/delivery.js

    rc8e03c6 r6d5ce0c  
    4141// call-for-help path).
    4242export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
    43   const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
     43  const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
    4444          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
    4545  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     
    5050  const resolved = [];
    5151  for (const uri of list) {
    52     const a = await fetchActor(uri).catch(() => null);
     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);
    5357    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
    54     resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
     58    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
    5559  }
    5660  if (!resolved.length) return null;
     
    8387  const keyId = `${me}#main-key`;
    8488  let delivered = 0;
    85   for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
     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))]) {
    86100    let ok = false;
    87101    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
  • src/services/guardianship/handshake.js

    rc8e03c6 r6d5ce0c  
    219219    const lp = availability.parseLapse(activity.object);
    220220    if (lp) {
     221      // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
     222      // enforces, wherever it lives. A local ward is reached by the same
     223      // deliverTo, which loops back into the inbox handler; co-location is a
     224      // transport detail and never a shortcut past the decision.
    221225      const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
    222       const wardSlug = deps.localSlug(lp.ward);
    223       if (wardSlug) {
    224         const r = availability.openLapse({ id, wardSlug, wardUri: lp.ward, target: lp.target, openedBy: me, now: Date.now() });
    225         if (r.error) return { status: r.error === 'not_in_available_set' ? 403 : 409, error: r.error };
    226         deps.deliverTo(site, lp.target, { id, type: 'Offer', actor: me, to: [lp.target], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }).catch(() => { /* best-effort */ });
    227         notify(wardSlug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
    228         return { status: 202, id, url: id, 'shaer:set': r.set, 'shaer:threshold': r.threshold };
    229       }
    230226      const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
    231227      const delivered = await fanout(site, [lp.ward], offer);
Note: See TracChangeset for help on using the changeset viewer.