Changeset 024f4f8 in Klonkt


Ignore:
Timestamp:
07/20/2026 10:08:44 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
e6c6e6f
Parents:
81b2e1e
git-author:
Robin <roboburr@…> (07/20/2026 10:08:43 PM)
git-committer:
Robin <roboburr@…> (07/20/2026 10:08:44 PM)
Message:

Feature: direct notes are private mentions, not posts (shaer-tqc)

A C2S note addressed to specific actors (no Public, no followers) no
longer becomes a site post: it routes through the outbox machinery as
a direct note, the Mastodon DM model. deliverDirectNote resolves each
recipient, stores the note in ap_outbox (visibility 'direct' plus the
recipient list) and delivers to exactly those inboxes: no followers
fan-out. buildReplyNote addresses a direct row to its recipients only,
cc empty, so it cannot be boosted and never shows in timelines. This
is also the ward call-for-help leg: client to own outbox, server
S2S-signed to each guardian's inbox on any instance; a guardian on
plain Mastodon sees a private mention.

Hardening: a C2S Announce or Like of a non-public local post returns
403 not_public (the Mastodon 422 equivalent), and an inbound boost or
like on a fan_only/friends/direct post is dropped instead of stored.
Direct with no resolvable recipient is refused (400 no_recipients).

Changed files:
src/services/ActivityPubService.js

  • deliverDirectNote (resolve, store, deliver to recipients only)
  • ingest routes direct before the reply/post paths
  • buildReplyNote: direct rows -> to recipients, empty cc
  • C2S 403 + inbound drop on non-public targets

src/config/database.js

  • additive columns ap_outbox.visibility, ap_outbox.to_actors

New file:
test/c2s-direct.test.js

  • direct addressing pinned, no_recipients, 403 on boost/like

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

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r81b2e1e r024f4f8  
    441441  ensureColumn('ap_outbox', 'attachments', 'TEXT');
    442442  ensureColumn('posts', 'ap_visibility', 'TEXT');   // public|quiet|friends|direct (C2S addressing, shaer-60b)
     443  ensureColumn('ap_outbox', 'visibility', 'TEXT');  // 'direct' = private mention, never Public (shaer-tqc)
     444  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
    443445}
    444446
  • src/services/ActivityPubService.js

    r81b2e1e r024f4f8  
    252252      url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
    253253      published: toISO(post.created_at),
    254       to: post.to_actor ? [post.to_actor] : [PUBLIC],
    255       cc: [PUBLIC, `${meR}/followers`],
     254      // A direct note (private mention, shaer-tqc) addresses ONLY its
     255      // recipients: no Public anywhere, so it cannot be boosted and never
     256      // shows in public timelines (the Mastodon DM model).
     257      to: post.visibility === 'direct'
     258        ? (JSON.parse(post.to_actors || '[]'))
     259        : (post.to_actor ? [post.to_actor] : [PUBLIC]),
     260      cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`],
    256261      tag: [
    257262        ...mentionTags(post.content),
     
    13951400    const pid = postIdFromNoteUrl(objUrl, base);
    13961401    if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
     1402      // A boost/like of a non-public post is dropped, not stored: nobody
     1403      // outside the audience should even hold it (shaer-tqc hardening).
     1404      const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
     1405      if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
     1406        console.log('[AP] dropped', type, 'on non-public post', pid);
     1407        return;
     1408      }
    13971409      const ai = actorInfo(await resolveActor(actorUri), actorUri);
    13981410      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
     
    18261838        const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
    18271839        if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
     1840        // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
     1841        // outbox machinery to the addressed inboxes only; shows under Messages.
     1842        if (c2sVisibility(object) === 'direct') {
     1843          const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
     1844          const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
     1845            .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
     1846          if (!recipients.length) return { status: 400, error: 'no_recipients' };
     1847          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null });
     1848          if (!r || !r.id) return { status: 502, error: 'direct_failed' };
     1849          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
     1850        }
    18281851        if (object.inReplyTo) {
    18291852          const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
     
    18391862        const targetUri = c2sIdOf(object);
    18401863        if (!targetUri) return { status: 400, error: 'missing_object' };
     1864        // A non-public local note cannot be boosted or liked into the open
     1865        // (shaer-tqc hardening; the Mastodon 422 equivalent).
     1866        const localPid = postIdFromNoteUrl(targetUri, base);
     1867        if (localPid) {
     1868          const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
     1869          if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
     1870            return { status: 403, error: 'not_public' };
     1871          }
     1872        }
    18411873        const note = await resolveRemoteNote(targetUri).catch(() => null);
    18421874        const objUri = (note && note.object_uri) || targetUri;
     
    19211953  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
    19221954  return 'direct';
     1955}
     1956
     1957// A direct note (private mention, shaer-tqc): a NEW conversation (or a direct
     1958// reply) addressed to specific actors only. Stored in ap_outbox with
     1959// visibility 'direct' + the recipient list, delivered to exactly those
     1960// inboxes: no followers fan-out, no Public, so no boosts and no timelines.
     1961// The same S2S leg a Mastodon DM takes, so a guardian on any instance
     1962// receives it as a private mention (the ward call-for-help path).
     1963export async function deliverDirectNote(site, { recipients, text, language, inReplyTo }) {
     1964  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1965  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
     1966  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
     1967  const me = actorId(base, site.slug);
     1968  // Resolve every recipient for a mention anchor + a delivery inbox.
     1969  const resolved = [];
     1970  for (const uri of list) {
     1971    const a = await fetchActor(uri).catch(() => null);
     1972    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
     1973    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
     1974  }
     1975  if (!resolved.length) return null;
     1976  const mention = resolved.map((r) => {
     1977    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
     1978    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
     1979  }).join('');
     1980  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
     1981  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
     1982  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
     1983  const id = crypto.randomUUID();
     1984  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, created_at)
     1985              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
     1986    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, null, 'direct', JSON.stringify(resolved.map((r) => r.uri)));
     1987  const row = iStmts().getO.get(id);
     1988  const note = buildReplyNote(base, site, row);
     1989  const create = {
     1990    '@context': AP_CONTEXT,
     1991    id: note.id + '#create', type: 'Create', actor: me,
     1992    published: note.published, to: note.to, cc: note.cc, object: note,
     1993  };
     1994  const keys = getOrCreateKeys(site.slug);
     1995  const keyId = `${me}#main-key`;
     1996  let delivered = 0;
     1997  for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
     1998    let ok = false;
     1999    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
     2000    if (ok) delivered++;
     2001    else enqueueDelivery(site.slug, inbox, create);
     2002  }
     2003  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
     2004  return { id, content, delivered };
    19232005}
    19242006
     
    29213003  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    29223004  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    2923   listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
     3005  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    29243006  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll,
    29253007  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Note: See TracChangeset for help on using the changeset viewer.