Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision bfd9c7325cd36c5b855d62c6a23001f6733ef4f8)
+++ src/services/ActivityPubService.js	(revision 8179fdb41a15e9de99e4eb136db707d7ff3c6781)
@@ -29,53 +29,8 @@
 import Blocklist from './BlocklistService.js';
 import * as Guardianship from './guardianship/index.js';
-
-const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
-// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
-// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
-// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
-// This is the same context shape Mastodon publishes, so Mastodon sees no change.
-const AP_CONTEXT = [
-  'https://www.w3.org/ns/activitystreams',
-  'https://w3id.org/security/v1',
-  {
-    toot: 'http://joinmastodon.org/ns#',
-    schema: 'http://schema.org#',
-    sensitive: 'as:sensitive',
-    Hashtag: 'as:Hashtag',
-    manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
-    discoverable: 'toot:discoverable',
-    // FEP-7628 (account moves): same term declaration Mastodon ships.
-    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
-    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
-    featured: { '@id': 'toot:featured', '@type': '@id' },
-    PropertyValue: 'schema:PropertyValue',
-    value: 'schema:value',
-    embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
-    // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
-    // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
-    // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
-    // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
-    // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
-    license: { '@id': 'schema:license', '@type': '@id' },
-    position: 'schema:position',
-    bitrate: 'schema:bitrate',
-    size: 'schema:contentSize',
-    // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
-    // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
-    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
-    votersCount: 'toot:votersCount',
-    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
-    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
-    // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
-    // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
-    // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
-    // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
-    // antwoordt.
-    category: { '@id': 'https://funkwhale.audio/ns#category' },
-    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
-    // module (src/services/guardianship/).
-    ...Guardianship.SHAER_CONTEXT,
-  },
-];
+import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType } from './ap-core.js';
+// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
+export { AP_CONTEXT, actorId, noteId, guessMediaType };
+
 
 // Short random suffix so two activity ids minted in the same millisecond (e.g.
@@ -85,5 +40,4 @@
 // Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
 // smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
-const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
 
 // ── SSRF guard for outbound fetches ───────────────────────────────
@@ -259,22 +213,5 @@
 
 // ── document builders ─────────────────────────────────────────────
-export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
-
-/**
- * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
- * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
- * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
- * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
- * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
- */
-export function guessMediaType(u) {
-  const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
-  return ({
-    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-    webp: 'image/webp', avif: 'image/avif',
-    mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
-  })[(e || '').toLowerCase()] || 'image/jpeg';
-}
-export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
+
 
 /** Eén Link uit een AS2 `url` kiezen op mediaType. Een `url` mag een string,
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 8179fdb41a15e9de99e4eb136db707d7ff3c6781)
+++ src/services/ap-core.js	(revision 8179fdb41a15e9de99e4eb136db707d7ff3c6781)
@@ -0,0 +1,94 @@
+/**
+ * De primitieven die iedereen die ActivityPub uitzendt nodig heeft.
+ *
+ * Waarom dit bestand er is (shaer-drc): ActivityPubService.js was 6436 regels
+ * met 166 exports en tweeentwintig secties. Een submap zoals music/ kan pas
+ * zelfstandig bestaan als deze zes dingen ergens staan waar BEIDE uit kunnen
+ * putten -- anders importeert de submap uit ActivityPubService en importeert
+ * die weer terug, en dat is een kring.
+ *
+ * Guardianship laat zien hoe het wel moet: die map importeert alleen db en zijn
+ * eigen buren, nooit terug. Dit bestand maakt datzelfde mogelijk voor de rest.
+ *
+ * Alles hier is PUUR: geen database, geen netwerk, geen toestand. Dat is de
+ * grens -- komt daar iets bij dat wel iets weet, dan hoort het hier niet.
+ *
+ * EEN UITZONDERING OP "PUUR": AP_CONTEXT stelt zichzelf samen uit
+ * Guardianship.SHAER_CONTEXT. Die context is nu eenmaal de optelsom van ieders
+ * termen, dus dat hoort zo. Het maakt geen kring: guardianship kent alleen db
+ * en zijn eigen buren en importeert nooit terug.
+ */
+
+import * as Guardianship from './guardianship/index.js';
+
+export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
+// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
+// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
+// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
+// This is the same context shape Mastodon publishes, so Mastodon sees no change.
+export const AP_CONTEXT = [
+  'https://www.w3.org/ns/activitystreams',
+  'https://w3id.org/security/v1',
+  {
+    toot: 'http://joinmastodon.org/ns#',
+    schema: 'http://schema.org#',
+    sensitive: 'as:sensitive',
+    Hashtag: 'as:Hashtag',
+    manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
+    discoverable: 'toot:discoverable',
+    // FEP-7628 (account moves): same term declaration Mastodon ships.
+    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
+    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
+    featured: { '@id': 'toot:featured', '@type': '@id' },
+    PropertyValue: 'schema:PropertyValue',
+    value: 'schema:value',
+    embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
+    // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
+    // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
+    // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
+    // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
+    // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
+    license: { '@id': 'schema:license', '@type': '@id' },
+    position: 'schema:position',
+    bitrate: 'schema:bitrate',
+    size: 'schema:contentSize',
+    // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
+    // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
+    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
+    votersCount: 'toot:votersCount',
+    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
+    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
+    // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
+    // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
+    // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
+    // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
+    // antwoordt.
+    category: { '@id': 'https://funkwhale.audio/ns#category' },
+    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
+    // module (src/services/guardianship/).
+    ...Guardianship.SHAER_CONTEXT,
+  },
+];
+
+/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
+ *  bruikbare URL vinden. */
+export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
+
+export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
+export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
+
+/**
+ * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
+ * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
+ * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
+ * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
+ * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
+ */
+export function guessMediaType(u) {
+  const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
+  return ({
+    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+    webp: 'image/webp', avif: 'image/avif',
+    mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
+  })[(e || '').toLowerCase()] || 'image/jpeg';
+}
