Changeset 6a99668 in Klonkt


Ignore:
Timestamp:
07/30/2026 12:38:57 PM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
55eca8b
Parents:
f9b1c5c
Message:

Reply op een ander gefixt: gesigneerd resolven, eigen replies terug in de feed, duplicaat idempotent

Robins waarneming (30-7) legde de keten bloot: reply op jezelf lukt,
reply op een ander geeft 502. Drie oorzaken, drie fixes.

Een: resolveRemoteNote haalde de parent-note ANONIEM op. Een publieke
note (je eigen post) geeft dat, maar een friends-only note (de
Shaer-standaard!) weigert een anonieme GET terecht. Het reply-, like-
en boost-pad over C2S resolven nu gesigneerd als de eigen actor
(asSlug), zodat de andere server ziet wie er vraagt en serveert wat de
vriendschap verdient. Zelfde principe als friends-get-the-history, nu
ook aan de vraagkant.

Twee: je eigen verzonden replies (ap_outbox) werden nergens over C2S
geserveerd. Je reply bestond overal behalve in je eigen app: dus je
probeerde het opnieuw. De C2S inbox-read krijgt een derde leg:
getSentNotes bouwt ze via buildReplyNote (inReplyTo, Mention-tags,
attachments, friends-adressering), met de eigen shaer:author erop en de
leidende mention gestript zoals de DM-leg dat doet.

Drie: die herhaalpoging liep in de duplicate-guard, die zonder id
antwoordde; de ingest maakte daar 502 reply_failed van. Een duplicaat
is nu idempotent succes met het BESTAANDE id.

Changed files:
src/services/ActivityPubService.js

  • resolveRemoteNote(url, {asSlug}): gesigneerde fetches (note, actor en de ancestor-keten); reply/like/boost/undo geven site.slug mee
  • getSentNotes(base, site): eigen ap_outbox-rijen als AS2 Notes
  • duplicate-guard geeft het bestaande id terug

src/routes/activitypub.js

  • C2S inbox-read: sent-leg naast timeline en messages, zelfde vorm

New file:
test/sent-notes.test.js

  • sent reply komt terug als threadbare Note (parent in to, Mention, media absoluut, ISO-published); duplicaat = zelfde id

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

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    rf9b1c5c r6a99668  
    280280    },
    281281  }));
    282   // Newest first over both, so the app can keep treating this as one feed.
    283   const items = [...posts, ...messages].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
     282  // Your OWN sent notes (replies and direct messages, ap_outbox): without
     283  // them a reply existed everywhere except in your own app, Messages showed
     284  // half a conversation, and a retry ran into the duplicate guard (Robins
     285  // melding, 30-7). Served like the other legs: same shape, one parser.
     286  const mine = AP.selfAuthor(base, auth.site);
     287  const sent = AP.getSentNotes(base, auth.site, 60).map((n) => ({
     288    id: `${n.id}#create`,
     289    type: 'Create',
     290    actor: me,
     291    published: n.published,
     292    // The leading mention anchor is addressing, not prose (the DM leg strips
     293    // it the same way); the Mention tags built from the full content stay.
     294    object: { ...n, content: AP.stripLeadingMentions(n.content), 'shaer:author': mine },
     295  }));
     296  // Newest first over all legs, so the app can keep treating this as one feed.
     297  const items = [...posts, ...messages, ...sent].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
    284298  AP.sendAP(res, {
    285299    '@context': AP.AP_CONTEXT,
  • src/services/ActivityPubService.js

    rf9b1c5c r6a99668  
    22382238}
    22392239
     2240// The account's own outbound notes (replies and direct messages) as AS2
     2241// Notes, newest first. The C2S inbox read serves these alongside the
     2242// timeline: without them your own reply existed everywhere EXCEPT in your
     2243// own app (Robins melding, 30-7: "replyen werkt nog niet"; het antwoord
     2244// stond op de server maar de app kreeg het nooit terug, dus je probeerde
     2245// het opnieuw en liep in de duplicate-guard).
     2246export function getSentNotes(base, site, limit = 60) {
     2247  return db.prepare('SELECT * FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC LIMIT ?')
     2248    .all(site.slug, limit)
     2249    .map((row) => buildReplyNote(base, site, row));
     2250}
     2251
    22402252// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
    22412253export function getOutboxNote(base, id) {
     
    23192331        }
    23202332        if (object.inReplyTo) {
    2321           const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
     2333          const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
    23222334          if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
    23232335          // The attachments ride along (Robins melding, 30-7: "502
     
    23592371          }
    23602372        }
    2361         const note = await resolveRemoteNote(targetUri).catch(() => null);
     2373        const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
    23622374        const objUri = (note && note.object_uri) || targetUri;
    23632375        const authorUri = note && note.actor_uri;
     
    23972409        if (innerType === 'Like' || innerType === 'Announce') {
    23982410          const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
    2399           const note = await resolveRemoteNote(innerTarget).catch(() => null);
     2411          const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
    24002412          const objUri = (note && note.object_uri) || innerTarget;
    24012413          await sendInteraction(site, kind, objUri, note && note.actor_uri);
     
    25862598  // Attachments count toward "the same": two media-only replies share content.
    25872599  const mediaJson = media.length ? JSON.stringify(media) : null;
    2588   const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
     2600  // A duplicate is idempotent success, not an error: it answers with the
     2601  // EXISTING id. Returning without one made the C2S ingest say 502
     2602  // reply_failed on a double-submit (Robins schermafdruk, 30-7), so a retry
     2603  // of a reply the app never showed looked like the reply itself failing.
     2604  const dup = db.prepare('SELECT id FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
    25892605    .get(site.slug, parent.object_uri || '', content, mediaJson);
    2590   if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
     2606  if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, id: dup.id, delivered: 0 }; }
    25912607  const id = crypto.randomUUID();
    25922608  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
     
    26432659// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
    26442660// Returns a parent-shaped object usable by deliverReply(), or null.
    2645 export async function resolveRemoteNote(url) {
     2661export async function resolveRemoteNote(url, opts = {}) {
    26462662  if (!/^https?:\/\//i.test(String(url || ''))) return null;
    2647   const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
     2663  // With `asSlug` the fetches are SIGNED as that local actor. An anonymous
     2664  // GET can only read public notes; a friends-only note (Shaer's default!)
     2665  // rightly refuses it, which made every reply to a friend's post fail while
     2666  // a reply to your own public post worked (Robins melding, 30-7). Signed,
     2667  // the other server sees WHO asks and serves what the friendship earns.
     2668  const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null));
     2669  const note = await get(url); // AP GET (content-negotiates)
    26482670  if (!note || !note.id) return null;
    26492671  const att = note.attributedTo;
    26502672  const actorUri = actorUriOf(att);
    26512673  if (!actorUri) return null;
    2652   const actor = await fetchActor(actorUri).catch(() => null);
     2674  const actor = await get(actorUri);
    26532675  const ai = actorInfo(actor, actorUri);
    26542676  // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
     
    26642686    const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
    26652687    if (!url) break;
    2666     const pn = await fetchActor(url).catch(() => null);
     2688    const pn = await get(url);
    26672689    if (!pn) break;
    26682690    const pa = actorUriOf(pn.attributedTo);
    26692691    if (pa && pa !== actorUri) {
    2670       const paDoc = await fetchActor(pa).catch(() => null);
     2692      const paDoc = await get(pa);
    26712693      const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
    26722694      if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
     
    41674189  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    41684190  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    4169   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
     4191  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
    41704192  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    41714193  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
Note: See TracChangeset for help on using the changeset viewer.