Index: docs/EXPORT-FORMAT.md
===================================================================
--- docs/EXPORT-FORMAT.md	(revision 621126bfda9f0449bb84e2e4e25257855a80b5c7)
+++ docs/EXPORT-FORMAT.md	(revision 712b1615e6ae1b194be929712df713bcd9082041)
@@ -102,5 +102,5 @@
 | `updated` | `posts.updated_at` | omitted when equal to `published` |
 | `url` | `<origin>/<slug>` | the human permalink |
-| `attachment` | cover, inline images, `c2s_attachments` | see [Media](#media) |
+| `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media) |
 | `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
 | `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
@@ -258,6 +258,9 @@
 ## Reproducibility
 
-Two exports of unchanged content must be **byte-identical**. That is what makes
-an archive verifiable and a diff meaningful.
+Two exports of unchanged content must be **byte-identical except for
+`manifest.exportedAt`**, which is by definition the moment of export. Everything
+else — every post file, every media file, and the `files` checksum map — must
+match. That is what makes an archive verifiable and a diff meaningful: if the
+checksum map is equal, nothing changed.
 
 - JSON: keys sorted, two-space indent, LF, trailing newline.
Index: scripts/export-archive.mjs
===================================================================
--- scripts/export-archive.mjs	(revision 712b1615e6ae1b194be929712df713bcd9082041)
+++ scripts/export-archive.mjs	(revision 712b1615e6ae1b194be929712df713bcd9082041)
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+//
+// Exporteer het inhoudsarchief van een site (shaer-1a6).
+//
+//     node scripts/export-archive.mjs <slug> --dry-run
+//     node scripts/export-archive.mjs <slug> --out boiert.zip
+//     node scripts/export-archive.mjs <slug> --dir ./archief
+//
+// Het formaat staat in docs/EXPORT-FORMAT.md.
+//
+// Dit is NIET de storage-zip: er zit geen sleutel, sessie, wachtwoordhash of
+// DM van een ander in. Wel je eigen concepten, en -- als je die hebt -- de
+// volledige inhoud van betaalde posts. Behandel het bestand als de inhoud zelf.
+
+import fs from 'fs';
+import path from 'path';
+import { buildArchive, zipArchive, writeArchiveDir } from '../src/services/ArchiveExportService.js';
+
+const args = process.argv.slice(2);
+const slug = args.find((a) => !a.startsWith('-'));
+const vlag = (naam) => { const i = args.indexOf(naam); return i >= 0 ? (args[i + 1] || true) : null; };
+
+if (!slug) {
+  console.error('gebruik: node scripts/export-archive.mjs <slug> [--dry-run | --out <zip> | --dir <map>]');
+  process.exit(1);
+}
+
+const uit = buildArchive(slug);
+const t = uit.counts;
+console.log(`site      : ${uit.manifest.site.slug} (${uit.manifest.origin || 'geen PUBLIC_BASE_URL'})`);
+console.log(`posts     : ${t.posts}`);
+console.log(`antwoorden: ${t.replies}   (alleen-lezen archief)`);
+console.log(`media     : ${t.media} meegenomen, ${t.mediaMissing} ontbrekend`);
+
+// Ontbrekende media worden GETELD en GEMELD, nooit stil overgeslagen: een post
+// waarvan het plaatje verdwenen is hoort dat te zeggen.
+if (uit.missing.length) {
+  console.log('\nontbrekende media:');
+  for (const m of uit.missing.slice(0, 20)) console.log(`  ${m.post}  ${m.url}`);
+  if (uit.missing.length > 20) console.log(`  ... en nog ${uit.missing.length - 20}`);
+}
+
+if (args.includes('--dry-run')) {
+  console.log(`\n--dry-run: ${uit.files.size} bestand(en) zouden geschreven worden. Niets geschreven.`);
+  process.exit(0);
+}
+
+const dir = vlag('--dir');
+if (dir && typeof dir === 'string') {
+  writeArchiveDir(uit.files, dir);
+  console.log(`\ngeschreven naar ${path.resolve(dir)}`);
+  process.exit(0);
+}
+
+const out = (typeof vlag('--out') === 'string' ? vlag('--out') : null) || `${slug}-archief.zip`;
+fs.writeFileSync(out, zipArchive(uit.files));
+console.log(`\ngeschreven: ${out} (${fs.statSync(out).size} bytes)`);
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 712b1615e6ae1b194be929712df713bcd9082041)
+++ src/services/ArchiveExportService.js	(revision 712b1615e6ae1b194be929712df713bcd9082041)
@@ -0,0 +1,359 @@
+/**
+ * Export van een draagbaar inhoudsarchief (shaer-1a6).
+ *
+ * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
+ * alleen wat de code doet, niet waarom het formaat zo is.
+ *
+ * Twee dingen zijn geen implementatiedetail maar eis:
+ *
+ *   REPRODUCEERBAAR  Twee exports van ongewijzigde inhoud horen byte-voor-byte
+ *                    gelijk te zijn, anders is een diff of een checksum nutteloos.
+ *                    Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
+ *                    in de postbestanden en een vaste mtime in de zip.
+ *   GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
+ *                    sleutel, sessie, hash of DM van een ander in.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+
+export const FORMAT_VERSION = 1;
+
+/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
+function stableJson(value) {
+  const sorteer = (v) => {
+    if (Array.isArray(v)) return v.map(sorteer);
+    if (v && typeof v === 'object') {
+      const uit = {};
+      for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
+      return uit;
+    }
+    return v;
+  };
+  return `${JSON.stringify(sorteer(value), null, 2)}\n`;
+}
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const toISO = (d) => { const t = Date.parse(d); return isNaN(t) ? null : new Date(t).toISOString(); };
+
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
+  mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
+  mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * Van een media-URL naar een bestand op schijf.
+ *
+ * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
+ * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
+ * een pad -- de content slaat allebei op.
+ *
+ * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
+ * anders een willekeurig bestand van de schijf het archief in trekken.
+ */
+function localMediaPath(url, origin) {
+  let p = String(url || '');
+  if (!p) return null;
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (`${u.protocol}//${u.host}` !== origin) return null;   // andermans host: nooit van onze schijf
+      p = u.pathname;
+    } catch { return null; }
+  }
+  if (!p.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
+  return abs;
+}
+
+/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
+function mediaRefsOf(post, origin) {
+  const uit = [];
+  const zie = new Set();
+  const voegToe = (url, name) => {
+    const u = String(url || '').trim();
+    if (!u || zie.has(u)) return;
+    zie.add(u);
+    uit.push({ url: u, name: name || null });
+  };
+  voegToe(post.cover_image_url, post.cover_alt);
+  voegToe(post.cover_video_url, post.cover_alt);
+  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1]);
+  try {
+    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
+      voegToe(a && a.url, a && a.name);
+      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
+      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
+      // pas op bij de export van echte data.
+      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null);
+    }
+  } catch { /* kapotte kolom blokkeert de export niet */ }
+  // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title);
+    } catch { /* geen audio-tabellen: niets te doen */ }
+  }
+  return uit;
+}
+
+/** De audio-metadata die alleen in de database staat en nergens anders uit te halen is. */
+function audioOf(post) {
+  const uit = [];
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
+      if (!t) continue;
+      uit.push({
+        'shaer:ref': `[[track:${t.id}]]`,
+        name: t.title, artist: t.artist || undefined, album: t.album || undefined,
+        duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
+        url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
+      });
+    } catch { /* idem */ }
+  }
+  return uit.length ? uit : undefined;
+}
+
+/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
+function postObject(post, site, origin, attachments) {
+  const heeftTitel = !!(post.title && String(post.title).trim());
+  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
+  const updated = toISO(post.updated_at);
+  const poll = (() => {
+    try {
+      const d = JSON.parse(post.poll_json || 'null');
+      if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
+      const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
+      return { multiple: !!d.multiple, opties, endTime: d.endTime || null };
+    } catch { return null; }
+  })();
+  const tags = [];
+  try {
+    for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
+      tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
+    }
+  } catch { /* tags zijn optioneel */ }
+
+  return {
+    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+    id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
+    type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
+    attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    name: heeftTitel ? post.title : undefined,
+    content: post.content || '',
+    contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
+    summary: post.content_warning || undefined,
+    sensitive: post.nsfw ? true : undefined,
+    published,
+    updated: (updated && updated !== published) ? updated : undefined,
+    url: `${origin}/${encodeURIComponent(post.slug)}`,
+    attachment: attachments.length ? attachments : undefined,
+    tag: tags.length ? tags : undefined,
+    ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
+    endTime: poll ? (poll.endTime || undefined) : undefined,
+    quoteUrl: post.quote_uri || undefined,
+    'shaer:quoteActor': post.quote_actor || undefined,
+    'shaer:slug': post.slug,
+    'shaer:status': post.status || 'draft',
+    'shaer:excerpt': post.excerpt || undefined,
+    'shaer:type': post.type || undefined,
+    'shaer:pinned': post.pinned ? true : undefined,
+    'shaer:noindex': post.noindex ? true : undefined,
+    'shaer:fanOnly': post.fan_only ? true : undefined,
+    'shaer:paid': post.paid ? true : undefined,
+    'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
+    'shaer:apVisibility': post.ap_visibility || undefined,
+    'shaer:publishAt': toISO(post.publish_at) || undefined,
+    'shaer:coverAlt': post.cover_alt || undefined,
+    'shaer:viewCount': post.view_count || undefined,
+    'shaer:audio': audioOf(post),
+  };
+}
+
+/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
+function readableMarkdown(post, obj) {
+  const fm = [
+    '---',
+    `title: ${JSON.stringify(post.title || post.slug)}`,
+    `slug: ${JSON.stringify(post.slug)}`,
+    `date: ${obj.published || ''}`,
+    `status: ${post.status || 'draft'}`,
+    post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
+    '---',
+  ].filter((l) => l !== null).join('\n');
+  return `${fm}\n${post.content || ''}\n`;
+}
+
+/**
+ * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
+ *
+ * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
+ * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
+ */
+export function buildArchive(slug, opts = {}) {
+  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+  if (!site) throw new Error(`onbekende site: ${slug}`);
+
+  const bestanden = new Map();       // pad -> Buffer
+  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
+  const ontbrekend = [];             // voor de rapportage van de beller
+
+  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
+  // is de volgorde van twee posts op dezelfde seconde niet bepaald.
+  const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
+                             ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
+
+  for (const post of posts) {
+    const attachments = [];
+    for (const ref of mediaRefsOf(post, origin)) {
+      const schijf = localMediaPath(ref.url, origin);
+      const mime = mimeOf(ref.url);
+      let bytes = null;
+      if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
+        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
+          'shaer:sha256': hash,
+        });
+      } else {
+        // De derde staat uit het formaat: we weten DAT het bestond en waar het
+        // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
+        tellingen.mediaMissing += 1;
+        const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
+        ontbrekend.push({ post: post.slug, url: orig });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
+        });
+      }
+    }
+
+    const obj = postObject(post, site, origin, attachments);
+    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
+    tellingen.posts += 1;
+
+    // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
+    let replies = [];
+    try {
+      replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
+                             ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
+    } catch { /* tabel kan ontbreken op een heel oude database */ }
+    if (replies.length) {
+      const coll = {
+        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
+        type: 'OrderedCollection',
+        'shaer:archive': true,
+        'shaer:inReplyTo': obj.id,
+        totalItems: replies.length,
+        orderedItems: replies.map((r) => ({
+          id: r.object_uri || undefined,
+          type: 'Note',
+          attributedTo: r.actor_uri || undefined,
+          inReplyTo: r.parent_uri || obj.id,
+          content: r.content || '',
+          published: toISO(r.published || r.created_at) || undefined,
+          'shaer:actorName': r.actor_name || undefined,
+          'shaer:actorHandle': r.actor_handle || undefined,
+        })),
+      };
+      bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
+      tellingen.replies += replies.length;
+    }
+  }
+
+  const files = {};
+  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    site: { slug: site.slug, title: site.title || site.slug },
+    counts: tellingen,
+    files,
+  };
+  bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
+}
+
+// ── Zip, store-only en deterministisch ────────────────────────────
+// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
+// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
+// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
+
+const _crcTabel = (() => {
+  const t = new Int32Array(256);
+  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
+  return t;
+})();
+function crc32(buf) {
+  let c = 0 ^ -1;
+  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
+  return (c ^ -1) >>> 0;
+}
+
+export function zipArchive(files) {
+  const paden = [...files.keys()].sort();
+  const lokaal = [];
+  const centraal = [];
+  let offset = 0;
+  for (const pad of paden) {
+    const naam = Buffer.from(pad, 'utf8');
+    const data = files.get(pad);
+    const crc = crc32(data);
+    const lh = Buffer.alloc(30);
+    lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
+    lh.writeUInt16LE(0, 8);                       // store, geen compressie
+    lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12);   // vaste tijd: 1980-01-01
+    lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
+    lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
+    lokaal.push(lh, naam, data);
+
+    const ch = Buffer.alloc(46);
+    ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
+    ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
+    ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
+    ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
+    ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
+    ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
+    ch.writeUInt32LE(offset, 42);
+    centraal.push(ch, naam);
+    offset += 30 + naam.length + data.length;
+  }
+  const cd = Buffer.concat(centraal);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0);
+  eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
+  eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
+  return Buffer.concat([...lokaal, cd, eocd]);
+}
+
+/** Schrijf het archief als losse bestanden naar een map. */
+export function writeArchiveDir(files, dir) {
+  for (const pad of [...files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, files.get(pad));
+  }
+}
Index: test/archive-export.test.js
===================================================================
--- test/archive-export.test.js	(revision 712b1615e6ae1b194be929712df713bcd9082041)
+++ test/archive-export.test.js	(revision 712b1615e6ae1b194be929712df713bcd9082041)
@@ -0,0 +1,227 @@
+// De exporter (shaer-1a6), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
+//
+// De acceptatietest uit dat document is de MOEILIJKSTE post die Klonkt kan
+// maken, niet de makkelijkste: een poll met bijlagen, een quote-kaart, een
+// content warning en betaalde toegang. Die staat hieronder als eerste.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+// De media-map moet bestaan voordat paths.js hem uitleest.
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-media-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AX = await import('../src/services/ArchiveExportService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
+const PLAATJE = Buffer.from('dit-zijn-de-bytes-van-een-plaatje');
+fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
+const PLAATJE_HASH = crypto.createHash('sha256').update(PLAATJE).digest('hex');
+
+function post(id, extra = {}) {
+  const kol = {
+    id, site_id: 's1', slug: id, author_id: 'u1', title: 'Titel', content: '<p>inhoud</p>',
+    status: 'published', published_at: '2026-08-01 10:00:00', created_at: '2026-08-01 10:00:00',
+    updated_at: '2026-08-01 10:00:00', ...extra,
+  };
+  const namen = Object.keys(kol);
+  db.prepare(`INSERT INTO posts (${namen.join(',')}) VALUES (${namen.map(() => '?').join(',')})`)
+    .run(...namen.map((n) => kol[n]));
+}
+
+const lees = (uit, pad) => JSON.parse(uit.files.get(pad).toString('utf8'));
+
+test('de moeilijkste post overleeft de export heel', () => {
+  post('zwaar', {
+    title: 'Een zware post',
+    content: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">',
+    content_warning: 'spoiler', nsfw: 1, paid: 1, paid_min_cents: 500,
+    quote_uri: 'https://mstdn.social/users/x/statuses/1', quote_actor: 'https://mstdn.social/users/x',
+    poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2026-08-09T10:00:00Z' }),
+    language: 'nl', tags: 'muziek, code',
+  });
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/zwaar.json');
+
+  assert.equal(o.type, 'Question', 'een poll exporteert als Question');
+  assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
+  assert.equal(o.endTime, '2026-08-09T10:00:00Z');
+  assert.equal(o.summary, 'spoiler', 'de content warning is de AS2 summary');
+  assert.equal(o.sensitive, true);
+  assert.equal(o.quoteUrl, 'https://mstdn.social/users/x/statuses/1');
+  assert.equal(o['shaer:quoteActor'], 'https://mstdn.social/users/x');
+  assert.equal(o['shaer:paidMinCents'], 500);
+  assert.deepEqual(o.contentMap, { nl: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">' });
+  assert.deepEqual(o.tag.map((t) => t.name), ['#muziek', '#code']);
+});
+
+test('een betaalde post exporteert de VOLLEDIGE inhoud, niet de teaser', () => {
+  // buildNote() federeert bewust alleen een teaser. Zou de export die projectie
+  // volgen, dan archiveer je je eigen betaalde post als samenvatting.
+  const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
+  assert.match(o.content, /<p>body<\/p>/);
+  assert.equal(o['shaer:paid'], true);
+});
+
+test('de titel staat in name, niet in de content gebakken', () => {
+  // Ook een verschil met de federatie-projectie: Mastodon negeert `name`, dus
+  // daar gaat de titel de content in. Een archief hoort dat niet over te nemen.
+  const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
+  assert.equal(o.name, 'Een zware post');
+  assert.equal(o.type, 'Question');
+  assert.doesNotMatch(o.content, /<strong>Een zware post<\/strong>/);
+});
+
+test('media reizen mee als bytes, gesleuteld op hun inhoud', () => {
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/zwaar.json');
+  const a = o.attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a['shaer:sha256'], PLAATJE_HASH);
+  assert.equal(a.url, `media/${PLAATJE_HASH}.jpg`, 'de verwijzing is container-relatief');
+  assert.equal(a['shaer:originalUrl'], 'https://klonkt.test/media/2026/cover.jpg');
+  assert.deepEqual(uit.files.get(a.url), PLAATJE, 'en de bytes zitten er echt in');
+});
+
+test('een verdwenen bestand wordt gemeld, niet weggelaten', () => {
+  // De derde mediastaat. Een lege medialijst zou een leugen zijn en een
+  // verwijzing naar een ontbrekend bestand een kapot archief.
+  post('kwijt', { content: '<p>x</p><img src="/media/2026/weg.jpg">' });
+  const uit = AX.buildArchive('me');
+  const a = lees(uit, 'posts/kwijt.json').attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+  assert.equal(a.url, 'https://klonkt.test/media/2026/weg.jpg', 'de oorspronkelijke plek blijft staan');
+  assert.equal(uit.counts.mediaMissing, 1);
+  assert.equal(uit.missing[0].post, 'kwijt');
+});
+
+test('een pad buiten de media-map wordt niet gevolgd', () => {
+  // Zonder deze grens trekt een verzonnen pad in oude inhoud een willekeurig
+  // bestand van de schijf het archief in.
+  post('sluipweg', { content: '<p>x</p><img src="/media/../../../etc/passwd">' });
+  const uit = AX.buildArchive('me');
+  const a = lees(uit, 'posts/sluipweg.json').attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+});
+
+test('antwoorden van anderen staan apart en zijn gemarkeerd als archief', () => {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, created_at)
+              VALUES ('reply', 'zwaar', ?, ?, 'Anna', '<p>hoi</p>', '2026-08-02 10:00:00')`)
+    .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
+  const uit = AX.buildArchive('me');
+  const c = lees(uit, 'replies/zwaar.json');
+  assert.equal(c['shaer:archive'], true, 'hierop keert een importer: nooit opnieuw bezorgen');
+  assert.equal(c.totalItems, 1);
+  assert.equal(c.orderedItems[0].id, 'https://mstdn.social/users/anna/statuses/9');
+  assert.equal(uit.counts.replies, 1);
+});
+
+test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
+  // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op
+  // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier.
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const WAV = Buffer.from('RIFF-nep-audio');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license, link_spotify)
+              VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY','https://open.spotify.com/track/x')`).run();
+  post('metaudio', { content: '<p>luister</p>[[track:tr1]]' });
+
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/metaudio.json');
+  const a = o.attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a.mediaType, 'audio/mpeg');
+  assert.deepEqual(uit.files.get(a.url), WAV);
+  assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
+  const t = o['shaer:audio'][0];
+  assert.equal(t.name, 'Kanonnen');
+  assert.equal(t.artist, 'Robin');
+  assert.equal(t.license, 'CC BY');
+  assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
+});
+
+test('de poster van een audio-bijlage gaat mee', () => {
+  // Gevonden bij de export van echte beta-data: c2s_attachments draagt naast url
+  // ook een poster, en die viel er stil uit.
+  fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a'), Buffer.from('audio'));
+  fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a.poster.webp'), Buffer.from('poster'));
+  post('metposter', {
+    c2s_attachments: JSON.stringify([{
+      url: '/media/2026/opname.m4a', mediaType: 'audio/mp4', name: 'opname.m4a',
+      poster: '/media/2026/opname.m4a.poster.webp',
+    }]),
+  });
+  const o = lees(AX.buildArchive('me'), 'posts/metposter.json');
+  assert.equal(o.attachment.length, 2, 'de opname en de poster');
+  assert.ok(o.attachment.every((a) => a['shaer:availability'] === 'included'));
+});
+
+test('een concept gaat gewoon mee', () => {
+  post('concept', { status: 'draft', published_at: null });
+  const o = lees(AX.buildArchive('me'), 'posts/concept.json');
+  assert.equal(o['shaer:status'], 'draft');
+});
+
+test('twee exports van ongewijzigde inhoud zijn gelijk, op exportedAt na', () => {
+  const a = AX.buildArchive('me', { exportedAt: 'X' });
+  const b = AX.buildArchive('me', { exportedAt: 'Y' });
+  assert.deepEqual([...a.files.keys()].sort(), [...b.files.keys()].sort());
+  for (const pad of a.files.keys()) {
+    if (pad === 'manifest.json') continue;
+    assert.deepEqual(a.files.get(pad), b.files.get(pad), `${pad} verschilt`);
+  }
+  assert.deepEqual(a.manifest.files, b.manifest.files, 'de checksum-kaart hoort gelijk te zijn');
+});
+
+test('en de zip die eruit komt ook', () => {
+  const a = AX.buildArchive('me', { exportedAt: 'X' });
+  const b = AX.buildArchive('me', { exportedAt: 'X' });
+  assert.deepEqual(AX.zipArchive(a.files), AX.zipArchive(b.files), 'zonder vaste mtime en volgorde is dit toeval');
+});
+
+test('de zip is door unzip te lezen', async () => {
+  // Een zelfgeschreven zip die alleen zichzelf kan lezen is geen uitwisselformaat.
+  const uit = AX.buildArchive('me');
+  const zip = path.join(MEDIA, 'test.zip');
+  fs.writeFileSync(zip, AX.zipArchive(uit.files));
+  const { execFileSync } = await import('child_process');
+  let lijst;
+  try { lijst = execFileSync('unzip', ['-l', zip], { encoding: 'utf8' }); }
+  catch (e) { if (e.code === 'ENOENT') return; throw e; }   // geen unzip op deze machine
+  assert.match(lijst, /manifest\.json/);
+  assert.match(lijst, /posts\/zwaar\.json/);
+});
+
+test('het manifest draagt de origin, want daar hangt het behoud van AP-ids aan', () => {
+  const m = AX.buildArchive('me').manifest;
+  assert.equal(m.origin, 'https://klonkt.test');
+  assert.equal(m.formatVersion, 1);
+  assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
+});
+
+test('posts van een ANDERE site komen er niet in', () => {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
+  db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+    .run('vanbuur', 's2', 'vanbuur', 'u1', 'Niet van mij', '<p>x</p>', 'published');
+  assert.equal(AX.buildArchive('me').files.has('posts/vanbuur.json'), false);
+});
+
+test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
