Changeset fb6819d in Klonkt


Ignore:
Timestamp:
06/29/2026 09:59:19 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
f2796d3
Parents:
7475ff3
Message:

fix(federation): make boosts spec-addressable + durable so receivers attribute them

Boosts/likes were fire-and-forget with a fragment activity id and no audience
addressing for the original author, so a multi-user receiver (e.g. WordPress's
ActivityPub plugin) had nothing to route the Announce to and never recorded it,
while Mastodon's boost (which cc's the author) did show.

  • src/services/ActivityPubService.js (sendInteraction) — Announce/Undo now cc the original author next to our followers and deliver to the author's PERSONAL inbox, use non-fragment dereferenceable activity ids, and add published (Mastodon parity); delivery now runs through deliverWithRetry (ap_delivery queue + backoff) instead of a single best-effort POST, so a transient hiccup no longer loses the boost

Co-Authored-By: Claude <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r7475ff3 rfb6819d  
    15001500  // reblog (matched on actor+object — no record of the original Announce needed).
    15011501  const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
     1502  const followersCol = `${me}/followers`;
     1503  // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
     1504  // attributes the boost to their post and notifies them — without this, a shared-inbox
     1505  // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
     1506  // stamp keep us aligned with what Mastodon emits.
     1507  const audience = authorUri ? [followersCol, authorUri] : [followersCol];
    15021508  let act;
    15031509  if (kind === 'unboost' || kind === 'unlike') {
     
    15071513    act = {
    15081514      '@context': 'https://www.w3.org/ns/activitystreams',
    1509       id: `${me}#undo-${Date.now()}-${rid()}`, type: 'Undo', actor: me,
    1510       object: { id: `${me}#${inner.toLowerCase()}-${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
     1515      id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
     1516      object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
    15111517    };
    1512     if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
     1518    if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
    15131519  } else {
    15141520    const type = kind === 'boost' ? 'Announce' : 'Like';
    15151521    act = {
    15161522      '@context': 'https://www.w3.org/ns/activitystreams',
    1517       id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
     1523      id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
    15181524      type, actor: me, object: targetNoteId,
    15191525    };
    1520     if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
     1526    if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
    15211527  }
    15221528  const inboxes = new Set();
    1523   if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
     1529  // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
     1530  // routes the Announce/Like to the right post unambiguously.
     1531  if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
    15241532  if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
    1525   let delivered = 0;
    1526   for (const inbox of [...inboxes].filter(Boolean)) { try { const st = await deliver(inbox, act, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ } }
    1527   console.log('[AP]', kind, site.slug, '→', targetNoteId, 'delivered', delivered);
    1528   return { ok: true, delivered };
     1533  // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
     1534  // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
     1535  // silently lose the boost — same durability a new post (deliverCreate) already gets.
     1536  let queued = 0;
     1537  for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
     1538  console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
     1539  return { ok: true, delivered: queued };
    15291540}
    15301541
Note: See TracChangeset for help on using the changeset viewer.