Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ CHANGELOG.de.md	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
@@ -16,6 +16,14 @@
   `/.well-known/oauth-authorization-server` (RFC 8414) liefert die Metadaten,
   sodass Clients alles entdecken statt Pfade festzuschreiben. Nur öffentliche
-  Clients + PKCE, keine Client-Secrets. Die Token-annehmende Outbox (POST) ist
-  die nächste Phase.
+  Clients + PKCE, keine Client-Secrets.
+- **Die Outbox nimmt Beiträge von Apps an (C2S, Phase 1 komplett).** Ein
+  `POST` mit Bearer-Token an `/ap/users/:slug/outbox` steuert jetzt dein Konto
+  aus einer App: einen Beitrag veröffentlichen, antworten, liken, teilen, folgen
+  und all das rückgängig machen. Aktivitäten laufen über dieselbe
+  Zustell-Maschinerie wie die Web-UI; eine nackte Note wird laut Spezifikation in
+  ein Create verpackt; Inhalt wird bereinigt; das Token ist an eine Seite
+  gebunden. Hinweis: Das ist ActivityPub C2S, das die Shaer-Apps sprechen.
+  Mastodon-Clients (Ivory usw.) nutzen Mastodons eigene API und werden hier nicht
+  unterstützt.
 
 ### Behoben
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ CHANGELOG.md	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
@@ -15,5 +15,13 @@
   `/.well-known/oauth-authorization-server` (RFC 8414) exposes the metadata, so
   clients discover everything instead of hardcoding paths. Public clients + PKCE
-  only, no client secrets. The token-accepting outbox (POST) is the next phase.
+  only, no client secrets.
+- **The outbox accepts posts from apps (C2S, phase 1 complete).** A
+  bearer-authenticated `POST` to `/ap/users/:slug/outbox` now drives your account
+  from a client: publish a note, reply, like, boost, follow, and undo any of
+  those. Activities are translated onto the same delivery machinery the web UI
+  uses; a bare Note is wrapped in a Create per the spec; content is sanitized;
+  the token is scoped to one site so it can't post as another. Note: this is
+  ActivityPub C2S, which the Shaer apps speak. Mastodon clients (Ivory etc.) use
+  Mastodon's own API and are not supported by this.
 
 ### Fixed
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ CHANGELOG.nl.md	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
@@ -15,6 +15,14 @@
   uploadMedia-endpoints en `/.well-known/oauth-authorization-server` (RFC 8414)
   geeft de metadata, dus apps ontdekken alles in plaats van paden vast te
-  spijkeren. Alleen publieke clients + PKCE, geen client-secrets. De outbox die
-  de tokens accepteert (POST) is de volgende fase.
+  spijkeren. Alleen publieke clients + PKCE, geen client-secrets.
+- **De outbox accepteert posts van apps (C2S, fase 1 compleet).** Een
+  `POST` met bearer-token naar `/ap/users/:slug/outbox` bestuurt nu je account
+  vanuit een app: een bericht plaatsen, reageren, liken, boosten, volgen en dat
+  allemaal ongedaan maken. Activities gaan via dezelfde bezorg-machinerie als de
+  web-UI; een kale Note wordt in een Create verpakt (spec); content wordt
+  gesanitized; het token is aan één site gebonden dus kan niet namens een andere
+  posten. Let op: dit is ActivityPub C2S, wat de Shaer-apps spreken.
+  Mastodon-clients (Ivory e.d.) gebruiken Mastodons eigen API en worden hier niet
+  ondersteund.
 
 ### Opgelost
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ src/routes/activitypub.js	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
@@ -18,4 +18,5 @@
 import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
 import { apEnabled } from '../services/SettingsService.js';
+import OAuth from '../services/OAuthService.js';
 
 const router = express.Router();
@@ -193,3 +194,21 @@
 });
 
+// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
+// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
+// the normal delivery machinery. The token is scoped to one user+site (OAuth
+// consent), so it must match the slug in the URL. (Declared after apJson, which
+// this shares with the inbox handler.)
+router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
+  if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
+  if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
+
+  const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
+  if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
+  // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
+  if (out.status === 201 && out.url) res.set('Location', out.url);
+  return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
+});
+
 export default router;
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ src/services/ActivityPubService.js	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
@@ -1750,4 +1750,103 @@
 }
 
+// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
+// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
+// activity here and we translate it onto the SAME delivery machinery the web UI
+// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
+// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
+const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
+
+export async function ingestOutboxActivity(site, user, activity) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
+
+  // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
+  let type = activity.type;
+  let object = activity.object;
+  if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
+  if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
+
+  try {
+    switch (type) {
+      case 'Create': {
+        if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
+        // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
+        // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
+        const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
+        if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
+        if (object.inReplyTo) {
+          const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
+          if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
+          const r = await deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text: plain });
+          if (!r || !r.id) return { status: 502, error: 'reply_failed' };
+          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
+        }
+        return await c2sCreatePost(base, site, user, object);
+      }
+      case 'Like':
+      case 'Announce': {
+        const targetUri = c2sIdOf(object);
+        if (!targetUri) return { status: 400, error: 'missing_object' };
+        const note = await resolveRemoteNote(targetUri).catch(() => null);
+        const objUri = (note && note.object_uri) || targetUri;
+        const authorUri = note && note.actor_uri;
+        const kind = type === 'Announce' ? 'boost' : 'like';
+        await sendInteraction(site, kind, objUri, authorUri);
+        setMyReaction(site.slug, targetUri, kind, true);
+        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
+        return { status: 202, url: objUri };
+      }
+      case 'Follow': {
+        const actorUri = c2sIdOf(object);
+        if (!actorUri) return { status: 400, error: 'missing_object' };
+        await followActor(site, actorUri);
+        return { status: 202, url: actorUri };
+      }
+      case 'Undo': {
+        const inner = object && typeof object === 'object' ? object : null;
+        let innerType = inner && inner.type;
+        if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
+        const innerTarget = c2sIdOf(inner && inner.object);
+        if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
+        if (innerType === 'Like' || innerType === 'Announce') {
+          const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
+          const note = await resolveRemoteNote(innerTarget).catch(() => null);
+          const objUri = (note && note.object_uri) || innerTarget;
+          await sendInteraction(site, kind, objUri, note && note.actor_uri);
+          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
+          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
+          return { status: 202, url: objUri };
+        }
+        return { status: 400, error: 'unsupported_undo' };
+      }
+      // Delete/Update of arbitrary objects need the post-edit pipeline; tracked
+      // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
+      default:
+        return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
+    }
+  } catch (e) {
+    console.warn('[AP] C2S ingest failed:', e && e.message);
+    return { status: 500, error: 'ingest_error' };
+  }
+}
+
+// Create a top-level microblog post from a C2S Note and federate it. Minimal
+// sibling of the /posts/create route: sanitized HTML content, no title/cover.
+async function c2sCreatePost(base, site, user, object) {
+  const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
+  if (!html.trim()) return { status: 400, error: 'empty_note' };
+  const postId = crypto.randomUUID();
+  const slug = 'n-' + postId.slice(0, 8);
+  const now = new Date().toISOString();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, created_at, updated_at, published_at)
+              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
+    .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', now, now, now);
+  try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ }
+  bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
+  try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
+  deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now }).catch(() => { /* best-effort */ });
+  return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
+}
+
 // Send a reply FROM this site to a remote actor (in reply to their inbound reply).
 // `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
@@ -2659,4 +2758,4 @@
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
-  getMessages, notificationsSeenAt,
+  getMessages, notificationsSeenAt, ingestOutboxActivity,
 };
Index: test/c2s-outbox.test.js
===================================================================
--- test/c2s-outbox.test.js	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
+++ test/c2s-outbox.test.js	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
@@ -0,0 +1,78 @@
+// ActivityPub C2S — ingestOutboxActivity dispatch. Covers the deterministic,
+// no-network paths: top-level Note creation (real DB), bare-object wrapping, and
+// input validation. Network verbs (Like/Announce/Follow/Undo, replies) are
+// verified live against a running server; safeFetch's SSRF pre-flight makes them
+// non-deterministic to unit-test.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+const AP = await import('../src/services/ActivityPubService.js');
+dbMod.initializeDatabase();
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'robin', 'r@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('me');
+const user = db.prepare('SELECT * FROM users WHERE id = ?').get('u1');
+
+test('Create(Note) top-level → a published post with sanitized content', async () => {
+  const out = await AP.ingestOutboxActivity(site, user, {
+    type: 'Create',
+    object: { type: 'Note', content: '<p>Hallo fediverse <script>alert(1)</script></p>' },
+  });
+  assert.equal(out.status, 201);
+  assert.ok(out.id);
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(out.id);
+  assert.equal(post.status, 'published');
+  assert.equal(post.site_id, 's1');
+  assert.match(post.content, /Hallo fediverse/);
+  assert.doesNotMatch(post.content, /<script>/i); // sanitized
+});
+
+test('a bare Note (no Create wrapper) is wrapped and posted', async () => {
+  const out = await AP.ingestOutboxActivity(site, user, { type: 'Note', content: '<p>bare note</p>' });
+  assert.equal(out.status, 201);
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(out.id);
+  assert.match(post.content, /bare note/);
+});
+
+test('empty note → 400', async () => {
+  const out = await AP.ingestOutboxActivity(site, user, { type: 'Create', object: { type: 'Note', content: '' } });
+  assert.equal(out.status, 400);
+  assert.equal(out.error, 'empty_note');
+});
+
+test('unsupported activity type → 400 with detail', async () => {
+  const out = await AP.ingestOutboxActivity(site, user, { type: 'Arrive', object: 'x' });
+  assert.equal(out.status, 400);
+  assert.equal(out.error, 'unsupported_type');
+  assert.equal(out.detail, 'Arrive');
+});
+
+test('Like/Announce/Follow without an object → 400', async () => {
+  for (const type of ['Like', 'Announce', 'Follow']) {
+    const out = await AP.ingestOutboxActivity(site, user, { type, object: null });
+    assert.equal(out.status, 400, type);
+    assert.equal(out.error, 'missing_object', type);
+  }
+});
+
+test('Undo of an unknown inner type → 400', async () => {
+  const out = await AP.ingestOutboxActivity(site, user, { type: 'Undo', object: { type: 'Block', object: 'x' } });
+  assert.equal(out.status, 400);
+  assert.equal(out.error, 'unsupported_undo');
+});
+
+test('garbage input → 400, never throws', async () => {
+  assert.equal((await AP.ingestOutboxActivity(site, user, null)).status, 400);
+  assert.equal((await AP.ingestOutboxActivity(site, user, 'nope')).status, 400);
+  assert.equal((await AP.ingestOutboxActivity(site, user, { type: 'Create' })).error, 'missing_object');
+});
