Changeset dd568e7 in Klonkt


Ignore:
Timestamp:
07/19/2026 03:15:18 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
bf72108
Parents:
d49b60b
git-author:
Robin <roboburr@…> (07/19/2026 03:14:01 AM)
git-committer:
Robin <roboburr@…> (07/19/2026 03:15:18 AM)
Message:

Feature: OAuth C2S outbox POST — apps can drive the account (phase 1 done)

Second half of AP Client-to-Server: a bearer-authenticated POST to
/ap/users/:slug/outbox accepts activities and translates them onto the existing
delivery machinery (deliverReply / sendInteraction / followActor /
deliverCreate) rather than reimplementing federation.

  • ingestOutboxActivity(site, user, activity) dispatches Create(Note) (reply -> resolveRemoteNote + deliverReply; top-level -> a sanitized microblog post + deliverCreate), Like, Announce (+upsertBoostedNote), Follow, and Undo of Like/Announce/Follow. A bare Note is wrapped in a Create per AP section 6. Client "source" (plain) is preferred over "content" (HTML) for replies; top-level content is HtmlSanitizerService.sanitize()d. Unhandled verbs return a clear 400 rather than a silent no-op.
  • Route: bearer via OAuthService.verifyBearer; the token is scoped to one site, so a slug mismatch is 403 and a readonly account is 403; no token is 401 with WWW-Authenticate. 201+Location for created objects, 202 for side-effect verbs. Declared after apJson (shared with the inbox handler) to avoid a TDZ on the const.

Verified live end to end against a running server with a real OAuth token:
top-level Note -> 201 + Location, stored published + sanitized (script stripped)
+ served as a valid Note at /ap/notes/<id>; Like/Follow -> 202; unresolvable
reply -> honest 502; no-token 401, wrong-site 403, unsupported type 400. 7 new
unit tests for the deterministic dispatch paths (80 green).

Ivory and other Mastodon-API clients are NOT supported by this: they speak
Mastodon's REST API (/api/v1/apps, /api/v1/instance, secret-based OAuth), not AP
C2S. That's a separate track (klonkt-demo-mastapi). Delete/Update of arbitrary
objects deferred (klonkt-demo-c2sdel). Beads: klonkt-demo-1w4.

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

Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    rd49b60b rdd568e7  
    1616  `/.well-known/oauth-authorization-server` (RFC 8414) liefert die Metadaten,
    1717  sodass Clients alles entdecken statt Pfade festzuschreiben. Nur öffentliche
    18   Clients + PKCE, keine Client-Secrets. Die Token-annehmende Outbox (POST) ist
    19   die nächste Phase.
     18  Clients + PKCE, keine Client-Secrets.
     19- **Die Outbox nimmt Beiträge von Apps an (C2S, Phase 1 komplett).** Ein
     20  `POST` mit Bearer-Token an `/ap/users/:slug/outbox` steuert jetzt dein Konto
     21  aus einer App: einen Beitrag veröffentlichen, antworten, liken, teilen, folgen
     22  und all das rückgängig machen. Aktivitäten laufen über dieselbe
     23  Zustell-Maschinerie wie die Web-UI; eine nackte Note wird laut Spezifikation in
     24  ein Create verpackt; Inhalt wird bereinigt; das Token ist an eine Seite
     25  gebunden. Hinweis: Das ist ActivityPub C2S, das die Shaer-Apps sprechen.
     26  Mastodon-Clients (Ivory usw.) nutzen Mastodons eigene API und werden hier nicht
     27  unterstützt.
    2028
    2129### Behoben
  • CHANGELOG.md

    rd49b60b rdd568e7  
    1515  `/.well-known/oauth-authorization-server` (RFC 8414) exposes the metadata, so
    1616  clients discover everything instead of hardcoding paths. Public clients + PKCE
    17   only, no client secrets. The token-accepting outbox (POST) is the next phase.
     17  only, no client secrets.
     18- **The outbox accepts posts from apps (C2S, phase 1 complete).** A
     19  bearer-authenticated `POST` to `/ap/users/:slug/outbox` now drives your account
     20  from a client: publish a note, reply, like, boost, follow, and undo any of
     21  those. Activities are translated onto the same delivery machinery the web UI
     22  uses; a bare Note is wrapped in a Create per the spec; content is sanitized;
     23  the token is scoped to one site so it can't post as another. Note: this is
     24  ActivityPub C2S, which the Shaer apps speak. Mastodon clients (Ivory etc.) use
     25  Mastodon's own API and are not supported by this.
    1826
    1927### Fixed
  • CHANGELOG.nl.md

    rd49b60b rdd568e7  
    1515  uploadMedia-endpoints en `/.well-known/oauth-authorization-server` (RFC 8414)
    1616  geeft de metadata, dus apps ontdekken alles in plaats van paden vast te
    17   spijkeren. Alleen publieke clients + PKCE, geen client-secrets. De outbox die
    18   de tokens accepteert (POST) is de volgende fase.
     17  spijkeren. Alleen publieke clients + PKCE, geen client-secrets.
     18- **De outbox accepteert posts van apps (C2S, fase 1 compleet).** Een
     19  `POST` met bearer-token naar `/ap/users/:slug/outbox` bestuurt nu je account
     20  vanuit een app: een bericht plaatsen, reageren, liken, boosten, volgen en dat
     21  allemaal ongedaan maken. Activities gaan via dezelfde bezorg-machinerie als de
     22  web-UI; een kale Note wordt in een Create verpakt (spec); content wordt
     23  gesanitized; het token is aan één site gebonden dus kan niet namens een andere
     24  posten. Let op: dit is ActivityPub C2S, wat de Shaer-apps spreken.
     25  Mastodon-clients (Ivory e.d.) gebruiken Mastodons eigen API en worden hier niet
     26  ondersteund.
    1927
    2028### Opgelost
  • src/routes/activitypub.js

    rd49b60b rdd568e7  
    1818import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
    1919import { apEnabled } from '../services/SettingsService.js';
     20import OAuth from '../services/OAuthService.js';
    2021
    2122const router = express.Router();
     
    193194});
    194195
     196// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
     197// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
     198// the normal delivery machinery. The token is scoped to one user+site (OAuth
     199// consent), so it must match the slug in the URL. (Declared after apJson, which
     200// this shares with the inbox handler.)
     201router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
     202  const auth = OAuth.verifyBearer(req.headers.authorization);
     203  if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
     204  if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
     205  if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
     206
     207  const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
     208  if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
     209  // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
     210  if (out.status === 201 && out.url) res.set('Location', out.url);
     211  return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
     212});
     213
    195214export default router;
  • src/services/ActivityPubService.js

    rd49b60b rdd568e7  
    17501750}
    17511751
     1752// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
     1753// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
     1754// activity here and we translate it onto the SAME delivery machinery the web UI
     1755// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
     1756// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
     1757const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
     1758
     1759export async function ingestOutboxActivity(site, user, activity) {
     1760  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1761  if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
     1762
     1763  // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
     1764  let type = activity.type;
     1765  let object = activity.object;
     1766  if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
     1767  if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
     1768
     1769  try {
     1770    switch (type) {
     1771      case 'Create': {
     1772        if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
     1773        // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
     1774        // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
     1775        const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
     1776        if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
     1777        if (object.inReplyTo) {
     1778          const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
     1779          if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
     1780          const r = await deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text: plain });
     1781          if (!r || !r.id) return { status: 502, error: 'reply_failed' };
     1782          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
     1783        }
     1784        return await c2sCreatePost(base, site, user, object);
     1785      }
     1786      case 'Like':
     1787      case 'Announce': {
     1788        const targetUri = c2sIdOf(object);
     1789        if (!targetUri) return { status: 400, error: 'missing_object' };
     1790        const note = await resolveRemoteNote(targetUri).catch(() => null);
     1791        const objUri = (note && note.object_uri) || targetUri;
     1792        const authorUri = note && note.actor_uri;
     1793        const kind = type === 'Announce' ? 'boost' : 'like';
     1794        await sendInteraction(site, kind, objUri, authorUri);
     1795        setMyReaction(site.slug, targetUri, kind, true);
     1796        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
     1797        return { status: 202, url: objUri };
     1798      }
     1799      case 'Follow': {
     1800        const actorUri = c2sIdOf(object);
     1801        if (!actorUri) return { status: 400, error: 'missing_object' };
     1802        await followActor(site, actorUri);
     1803        return { status: 202, url: actorUri };
     1804      }
     1805      case 'Undo': {
     1806        const inner = object && typeof object === 'object' ? object : null;
     1807        let innerType = inner && inner.type;
     1808        if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
     1809        const innerTarget = c2sIdOf(inner && inner.object);
     1810        if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
     1811        if (innerType === 'Like' || innerType === 'Announce') {
     1812          const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
     1813          const note = await resolveRemoteNote(innerTarget).catch(() => null);
     1814          const objUri = (note && note.object_uri) || innerTarget;
     1815          await sendInteraction(site, kind, objUri, note && note.actor_uri);
     1816          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
     1817          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
     1818          return { status: 202, url: objUri };
     1819        }
     1820        return { status: 400, error: 'unsupported_undo' };
     1821      }
     1822      // Delete/Update of arbitrary objects need the post-edit pipeline; tracked
     1823      // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
     1824      default:
     1825        return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
     1826    }
     1827  } catch (e) {
     1828    console.warn('[AP] C2S ingest failed:', e && e.message);
     1829    return { status: 500, error: 'ingest_error' };
     1830  }
     1831}
     1832
     1833// Create a top-level microblog post from a C2S Note and federate it. Minimal
     1834// sibling of the /posts/create route: sanitized HTML content, no title/cover.
     1835async function c2sCreatePost(base, site, user, object) {
     1836  const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
     1837  if (!html.trim()) return { status: 400, error: 'empty_note' };
     1838  const postId = crypto.randomUUID();
     1839  const slug = 'n-' + postId.slice(0, 8);
     1840  const now = new Date().toISOString();
     1841  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, created_at, updated_at, published_at)
     1842              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
     1843    .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', now, now, now);
     1844  try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ }
     1845  bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
     1846  try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
     1847  deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now }).catch(() => { /* best-effort */ });
     1848  return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
     1849}
     1850
    17521851// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
    17531852// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
     
    26592758  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    26602759  noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
    2661   getMessages, notificationsSeenAt,
     2760  getMessages, notificationsSeenAt, ingestOutboxActivity,
    26622761};
Note: See TracChangeset for help on using the changeset viewer.