Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 7a93fcf58c1b98f0813247a86370ba0278bd9f98)
+++ src/config/database.js	(revision 6089c53ce58d9a815c32fa7f8ddb549efce96d68)
@@ -695,4 +695,5 @@
   ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
   ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
+  ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
   ensureColumn('ap_mentions', 'wave', 'INTEGER');  // inbound guardian wave
   // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 7a93fcf58c1b98f0813247a86370ba0278bd9f98)
+++ src/services/ActivityPubService.js	(revision 6089c53ce58d9a815c32fa7f8ddb549efce96d68)
@@ -374,4 +374,13 @@
   if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
   else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
+  // Media a C2S composer attached (shaer-j3uh): federate with their REAL
+  // mediaType, because the extension map below knows no audio and would call
+  // an m4a an Image. Images also live inline in the content, so the dedupe
+  // by URL keeps them single.
+  try {
+    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
+      if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType });
+    }
+  } catch { /* malformed never blocks the Note */ }
   let body = post.content || '';
   // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
@@ -467,5 +476,5 @@
   const attachment = urls.filter((x) => x && x.url)
     .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
-    .map((x) => { const mt = mediaType(x.url); // specific AS2 subtype (Image/Audio/Video) over generic Document
+    .map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback
       const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
       const a = { type: ty, mediaType: mt, url: x.url };
@@ -2237,5 +2246,9 @@
         // 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' };
+        // A picture (or a recording) can be the whole message: media-only
+        // notes pass here; c2sCreatePost validates the attachments themselves.
+        if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) {
+          return { status: 400, error: 'empty_note' };
+        }
         // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
         // outbox machinery to the addressed inboxes only; shows under Messages.
@@ -2359,5 +2372,23 @@
 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' };
+  // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
+  // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
+  // to be silently dropped here, so a photo post from the app arrived naked.
+  const media = (Array.isArray(object.attachment) ? object.attachment : [])
+    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
+      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
+    .slice(0, 4)
+    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
+  if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
+  // The web reads the post's content, so the media goes IN it (we build these
+  // tags ourselves from validated paths, after the sanitizer). buildNote
+  // strips <img> back out into AS2 attachments; audio/video tags stay for the
+  // web player and federate via c2s_attachments below.
+  const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
+  const mediaHtml = media.map((a) => {
+    if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
+    if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata" src="${a.url}"></audio></p>`;
+    return `<p><video controls playsinline src="${a.url}"></video></p>`;
+  }).join('');
   const postId = crypto.randomUUID();
   const slug = 'n-' + postId.slice(0, 8);
@@ -2372,10 +2403,11 @@
   db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
-    .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', fanOnly, vis, 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(() => {});
+    .run(postId, site.id, slug, user.id, '', html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
+  if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
+  try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
+  bakePostContentWithMentions(html + mediaHtml).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 */ }
   if (vis !== 'direct') {
-    deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis }).catch(() => { /* best-effort */ });
+    deliverCreate(site, { id: postId, slug, title: '', content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
   }
   return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
Index: test/c2s-compose.test.js
===================================================================
--- test/c2s-compose.test.js	(revision 6089c53ce58d9a815c32fa7f8ddb549efce96d68)
+++ test/c2s-compose.test.js	(revision 6089c53ce58d9a815c32fa7f8ddb549efce96d68)
@@ -0,0 +1,67 @@
+// The app's composer posts over C2S. A top-level Note with media used to lose
+// it silently: c2sCreatePost read only the content, so a photo post arrived
+// naked while the very same attachments worked fine on replies and DMs.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'robin', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
+const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('s1' ? 'kid' : 'kid');
+const user = db.prepare('SELECT * FROM users WHERE id = ?').get('u1');
+
+test('a C2S post carries its media: into the web content and out as AS2 attachments', async () => {
+  const r = await AP.ingestOutboxActivity(site, user, {
+    type: 'Create',
+    object: {
+      type: 'Note',
+      content: '<p>kijk dan</p>',
+      source: { content: 'kijk dan', mediaType: 'text/plain' },
+      to: ['https://test.example/ap/users/kid/followers'],
+      cc: ['https://www.w3.org/ns/activitystreams#Public'],
+      attachment: [
+        { type: 'Image', url: '/media/reply-media/foto.jpg', mediaType: 'image/jpeg', name: 'ons plein' },
+        { type: 'Audio', url: '/media/reply-media/opname.m4a', mediaType: 'audio/mp4' },
+        // Not ours: a remote URL must never be laundered into our media.
+        { type: 'Image', url: 'https://evil.test/x.jpg', mediaType: 'image/jpeg' },
+      ],
+    },
+  });
+  assert.equal(r.status, 201, 'the post is created');
+
+  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
+  assert.match(post.content, /<img src="\/media\/reply-media\/foto\.jpg" alt="ons plein">/, 'the web shows the photo');
+  assert.match(post.content, /<audio controls[^>]+src="\/media\/reply-media\/opname\.m4a">/, 'and plays the recording');
+  assert.ok(!post.content.includes('evil.test'), 'the stranger stays out');
+
+  const note = AP.buildNote('https://test.example', site, post);
+  const att = note.attachment || [];
+  const img = att.find((a) => a.url.endsWith('/media/reply-media/foto.jpg'));
+  const aud = att.find((a) => a.url.endsWith('/media/reply-media/opname.m4a'));
+  assert.ok(img && img.type === 'Image', 'the photo federates as an Image');
+  assert.equal(img.url, 'https://test.example/media/reply-media/foto.jpg', 'absolute, so any server can fetch it');
+  assert.equal(img.name, 'ons plein', 'alt text rides along');
+  assert.ok(aud, 'the recording federates too');
+  assert.equal(aud.type, 'Audio', 'as an Audio, not an Image: the stored mediaType wins over the extension map');
+  assert.equal(att.filter((a) => a.url.endsWith('foto.jpg')).length, 1, 'inline img + stored row dedupe to one');
+});
+
+test('a media-only post is a post, not an empty-note error', async () => {
+  const r = await AP.ingestOutboxActivity(site, user, {
+    type: 'Create',
+    object: {
+      type: 'Note', content: '',
+      to: ['https://test.example/ap/users/kid/followers'],
+      attachment: [{ type: 'Image', url: '/media/reply-media/alleen.jpg', mediaType: 'image/png' }],
+    },
+  });
+  assert.equal(r.status, 201, 'a picture can be the whole message');
+});
