Index: src/routes/ward.js
===================================================================
--- src/routes/ward.js	(revision 5412cbbcf42cb02fb3fb6d67ef433290c95bb38c)
+++ src/routes/ward.js	(revision 2086ea255ae8ea5a474fc805eda10bd71146c947)
@@ -14,6 +14,10 @@
  */
 import express from 'express';
+import multer from 'multer';
+import fs from 'fs';
+import { randomUUID } from 'crypto';
 import path from 'path';
 import { fileURLToPath } from 'url';
+import { mediaDir } from '../config/paths.js';
 import db from '../config/database.js';
 import { requireAuth } from '../middleware/auth.js';
@@ -112,4 +116,15 @@
       newTitle: i18nT(L, 'ward.new_title'),
       noFriends: i18nT(L, 'ward.no_friends'),
+      write: i18nT(L, 'ward.write'),
+      post: i18nT(L, 'ward.post'),
+      visibility: i18nT(L, 'ward.visibility'),
+      visFriends: i18nT(L, 'ward.vis_friends'),
+      visPublic: i18nT(L, 'ward.vis_public'),
+      addPhoto: i18nT(L, 'ward.add_photo'),
+      addAudio: i18nT(L, 'ward.add_audio'),
+      addVideo: i18nT(L, 'ward.add_video'),
+      posted: i18nT(L, 'ward.posted'),
+      uploading: i18nT(L, 'ward.uploading'),
+      tooBig: i18nT(L, 'ward.too_big'),
     },
   };
@@ -361,4 +376,100 @@
 });
 
+// Dezelfde media-map en dezelfde filters als de AP C2S-upload (/uploadMedia):
+// een ward die via de app post en een ward die via de browser post moeten in
+// dezelfde bak landen, anders federeren hun foto's van twee plekken.
+const WARD_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
+fs.mkdirSync(WARD_MEDIA_DIR, { recursive: true });
+const WARD_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
+const wardUpload = multer({
+  storage: multer.diskStorage({
+    destination: (req, file, cb) => cb(null, WARD_MEDIA_DIR),
+    filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
+  }),
+  limits: { fileSize: 32 * 1024 * 1024 },
+  fileFilter: (req, file, cb) => {
+    const ext = path.extname(file.originalname || '').toLowerCase();
+    if (!WARD_MEDIA_EXT.has(ext)) return cb(new Error('media_type'));
+    cb(null, true);
+  },
+});
+
+/**
+ * Media uploaden voor de composer. Sessie-geauthenticeerd, want /ward draait
+ * op je eigen ingelogde Klonkt; de AP-upload eromheen eist een bearer die de
+ * browser niet heeft. Geeft { url, mediaType, name } terug, precies de vorm
+ * die een note als attachment draagt.
+ */
+router.post('/api/upload', requireAuth, (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  wardUpload.single('file')(req, res, (err) => {
+    if (err) return res.status(400).json({ error: err.message === 'media_type' ? 'media_type' : 'upload_failed' });
+    if (!req.file) return res.status(400).json({ error: 'no_file' });
+    const mime = String(req.file.mimetype || '');
+    if (!/^(image|audio|video)\//.test(mime)) {
+      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
+      return res.status(400).json({ error: 'media_type' });
+    }
+    res.status(201).json({
+      url: '/media/reply-media/' + req.file.filename,
+      mediaType: mime,
+      name: String(req.file.originalname || '').slice(0, 120),
+    });
+  });
+});
+
+/**
+ * Een post maken. Bouwt precies de AS2 Create die Shaer over C2S stuurt en
+ * geeft hem aan ingestOutboxActivity: die doet de compose-poort (§5), de
+ * attachment-validatie en de zichtbaarheid. GEEN eigen posting-pad, want dan
+ * zou de web-ward langs een gate kunnen die de app-ward tegenhoudt.
+ *
+ * De zichtbaarheid volgt Shaers eigen keuzes: 'public' luidop, 'friends'
+ * alleen naar volgers, 'direct' naar genoemde mensen. Standaard 'friends',
+ * want een besloten post is de veiligere default voor een kind.
+ */
+router.post('/api/post', requireAuth, express.json({ limit: '16kb' }), async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = AP.actorId(base, site.slug);
+
+  const text = String(req.body?.text || '').trim().slice(0, 5000);
+  const media = Array.isArray(req.body?.media) ? req.body.media.slice(0, 4) : [];
+  if (!text && !media.length) return res.status(400).json({ error: 'empty' });
+
+  const vis = ['public', 'friends'].includes(req.body?.visibility) ? req.body.visibility : 'friends';
+  const followers = `${me}/followers`;
+  // Publiek luidop = Public in to; vrienden = alleen volgers, geen Public.
+  const to = vis === 'public' ? ['https://www.w3.org/ns/activitystreams#Public'] : [followers];
+
+  const attachment = media
+    .filter((m) => m && typeof m.url === 'string' && /^\/media\//.test(m.url) && /^(image|audio|video)\//.test(String(m.mediaType || '')))
+    .map((m) => ({
+      type: m.mediaType.startsWith('image/') ? 'Image' : m.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
+      mediaType: m.mediaType,
+      url: m.url,
+      name: String(m.name || '').slice(0, 120),
+    }));
+
+  const escaped = text.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
+  const content = escaped ? `<p>${escaped.replace(/\n/g, '<br>')}</p>` : '';
+
+  const r = await AP.ingestOutboxActivity(site, req.session.user, {
+    type: 'Create',
+    object: {
+      type: 'Note',
+      content,
+      source: { content: text, mediaType: 'text/plain' },
+      to, cc: [],
+      attachment: attachment.length ? attachment : undefined,
+    },
+  }).catch((e) => ({ status: 500, error: String(e && e.message || e) }));
+
+  if (!r || r.status >= 400) return res.status(r?.status || 502).json({ error: r?.error || 'post_failed' });
+  res.status(201).json({ ok: true, url: r.url || null });
+});
+
 function pwaAsset(rel, type) {
   return (req, res) => {
