Changeset dd568e7 in Klonkt for src/routes/activitypub.js


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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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;
Note: See TracChangeset for help on using the changeset viewer.