Index: src/services/EmbedResolver.js
===================================================================
--- src/services/EmbedResolver.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ src/services/EmbedResolver.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
@@ -0,0 +1,181 @@
+// One pipeline for everything you can drop a URL of into a post, and one visual
+// result. What differs is not what a reader sees but what FEDERATES.
+//
+// Resolution order (Robins besluit, shaer-277):
+//   1. ActivityPub object  → the FEP path. A quote of a fediverse object carries
+//      real semantics: FEP-044f `quote` + an FEP-e232 Link tag, the quoted
+//      author gets addressed, and the permission model applies. Never resolved
+//      over oEmbed, because oEmbed has none of that.
+//   2. Known provider      → the existing player (YouTube/Spotify/Bandcamp/…).
+//   3. oEmbed discovery    → the generic path, and the preferred implementation
+//      for everything outside the fediverse.
+//   4. Otherwise           → a plain link.
+//
+// Everything returns the SAME normalised shape, so one renderer draws them all
+// (the quote card). Pure except for the two injected fetchers, so the ordering
+// logic is unit-testable without a network.
+
+const OEMBED_LINK = /<link\b[^>]*>/gi;
+
+/** Pull the oEmbed endpoint out of a page's <link rel="alternate"> tags. */
+export function findOEmbedEndpoint(html) {
+  if (!html || typeof html !== 'string') return null;
+  for (const tag of html.match(OEMBED_LINK) || []) {
+    const type = (tag.match(/\btype\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
+    if (!/application\/(json|xml)\+oembed/i.test(type)) continue;
+    const rel = (tag.match(/\brel\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
+    if (rel && !/alternate/i.test(rel)) continue;
+    const href = (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1];
+    // JSON only: we do not parse the XML flavour.
+    if (href && /json/i.test(type)) return decodeEntities(href);
+  }
+  return null;
+}
+
+function decodeEntities(s) {
+  return String(s).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
+}
+
+/** Is this JSON an ActivityPub object we can quote? */
+export function looksLikeAPObject(doc) {
+  if (!doc || typeof doc !== 'object') return false;
+  const t = Array.isArray(doc.type) ? doc.type[0] : doc.type;
+  if (typeof t !== 'string') return false;
+  // Quotable content, not an actor and not an activity.
+  return ['Note', 'Article', 'Page', 'Video', 'Audio', 'Image', 'Question', 'Event'].includes(t)
+    && typeof doc.id === 'string';
+}
+
+/** An oEmbed payload → the shared card shape. */
+export function fromOEmbed(url, o) {
+  if (!o || typeof o !== 'object') return null;
+  const media = [];
+  if (o.thumbnail_url) media.push({ url: String(o.thumbnail_url), type: 'image/*' });
+  return {
+    kind: 'oembed',
+    url: typeof o.url === 'string' && /^https?:/i.test(o.url) ? o.url : url,
+    title: o.title ? String(o.title) : null,
+    author: (o.author_name || o.provider_name) ? {
+      name: o.author_name ? String(o.author_name) : String(o.provider_name),
+      handle: o.provider_name ? String(o.provider_name) : null,
+      icon: null,
+    } : null,
+    // `html` is the provider's own iframe. Kept separate from the card body so
+    // a caller can decide to frame it or to fall back to the thumbnail; it is
+    // never merged into sanitized note content.
+    html: typeof o.html === 'string' ? o.html : null,
+    provider: o.provider_name ? String(o.provider_name) : null,
+    media,
+  };
+}
+
+/** An AP object → the same shape a resolved quote already uses. */
+export function fromAPObject(url, doc, author) {
+  const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
+    : (doc.attributedTo && typeof doc.attributedTo.id === 'string' ? doc.attributedTo.id : null);
+  return {
+    kind: 'ap',
+    url: (typeof doc.url === 'string' && doc.url) || doc.id || url,
+    id: doc.id,
+    attributedTo: attributed,
+    title: doc.name ? String(doc.name) : null,
+    content: typeof doc.content === 'string' ? doc.content : '',
+    published: doc.published || null,
+    author: author || null,
+    media: [],
+  };
+}
+
+/**
+ * Resolve one URL to the shared card shape.
+ *
+ * @param {string} url
+ * @param {object} io
+ *   - getAP(url)      → the AP JSON (Accept: application/activity+json) or null
+ *   - getPage(url)    → the HTML body or null
+ *   - getJSON(url)    → arbitrary JSON (the oEmbed endpoint) or null
+ *   - actorOf(uri)    → { name, handle, icon } for the AP author, or null
+ *   - provider(url)   → the known-provider hit (AudioEmbedService.detectProvider)
+ */
+export async function resolveEmbed(url, io = {}) {
+  if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
+
+  // 1. ActivityPub first: it is the only path that carries quote semantics.
+  if (io.getAP) {
+    const doc = await io.getAP(url).catch(() => null);
+    if (looksLikeAPObject(doc)) {
+      const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
+        : (doc.attributedTo && doc.attributedTo.id);
+      const author = (attributed && io.actorOf) ? await io.actorOf(attributed).catch(() => null) : null;
+      return fromAPObject(url, doc, author);
+    }
+  }
+
+  // 2. A provider we already play ourselves.
+  if (io.provider) {
+    const p = io.provider(url);
+    if (p) return { kind: 'provider', url, provider: p.provider, id: p.id || null, media: [] };
+  }
+
+  // 3. oEmbed: the generic path for everything else.
+  if (io.getPage && io.getJSON) {
+    const page = await io.getPage(url).catch(() => null);
+    const endpoint = findOEmbedEndpoint(page);
+    if (endpoint) {
+      const o = await io.getJSON(endpoint).catch(() => null);
+      const card = fromOEmbed(url, o);
+      if (card) return card;
+    }
+  }
+
+  // 4. Nothing recognised it: a link stays a link.
+  return { kind: 'link', url, media: [] };
+}
+
+// ── The wired-up variant ──────────────────────────────────────────
+// The io above is injected so the ordering is testable without a network.
+// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
+// goes through safeFetch (which refuses private ranges and caps redirects),
+// so a hostile URL in a post cannot make the server probe an internal host.
+
+const MAX_BODY = 512_000;   // an oEmbed page/endpoint is small; refuse the rest
+
+async function safeText(safeFetch, url, accept) {
+  try {
+    const r = await safeFetch(url, { headers: { Accept: accept } });
+    if (!r.ok) return null;
+    if (Number(r.headers.get('content-length') || 0) > MAX_BODY) return null;
+    const body = await r.text();
+    return body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body;
+  } catch { return null; }
+}
+
+/**
+ * Bind the resolver to the live fetchers.
+ * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
+ */
+export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
+  return {
+    provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
+    getAP: async (u) => {
+      const body = await safeText(safeFetch, u, 'application/activity+json, application/ld+json');
+      if (!body) return null;
+      try { return JSON.parse(body); } catch { return null; }   // an HTML page is simply not AP
+    },
+    getPage: (u) => safeText(safeFetch, u, 'text/html'),
+    getJSON: async (u) => {
+      const body = await safeText(safeFetch, u, 'application/json');
+      if (!body) return null;
+      try { return JSON.parse(body); } catch { return null; }
+    },
+    actorOf: async (uri) => {
+      if (!fetchActor || !actorInfo) return null;
+      const doc = await fetchActor(uri).catch(() => null);
+      if (!doc) return null;
+      const ai = actorInfo(doc, uri);
+      return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
+    },
+  };
+}
+
+export default { resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Index: test/embed-resolver.test.js
===================================================================
--- test/embed-resolver.test.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
+++ test/embed-resolver.test.js	(revision 6bc2e31be027c19ca280d49b79711d4e4a1952db)
@@ -0,0 +1,121 @@
+// One embed pipeline (shaer-277): AP first (FEP semantics), then a known
+// provider, then oEmbed, then a plain link. All four end in the same shape.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+  resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed,
+} from '../src/services/EmbedResolver.js';
+
+const AP_NOTE = { id: 'https://s/objects/1', type: 'Note', content: '<p>hi</p>', attributedTo: 'https://s/users/alice', url: 'https://s/@alice/1' };
+const OEMBED_PAGE = '<html><head><link rel="alternate" type="application/json+oembed" href="https://v.example/oembed?url=x&amp;f=json"><title>t</title></head></html>';
+const OEMBED_JSON = { type: 'video', title: 'A talk', author_name: 'Ada', provider_name: 'Vid', html: '<iframe src="https://v.example/e/1"></iframe>', thumbnail_url: 'https://v.example/t.jpg' };
+
+const io = (over = {}) => ({
+  getAP: async () => null,
+  getPage: async () => null,
+  getJSON: async () => null,
+  actorOf: async () => ({ name: 'Alice', handle: '@alice@s', icon: null }),
+  provider: () => null,
+  ...over,
+});
+
+test('findOEmbedEndpoint reads the json+oembed link and decodes entities', () => {
+  assert.equal(findOEmbedEndpoint(OEMBED_PAGE), 'https://v.example/oembed?url=x&f=json');
+  assert.equal(findOEmbedEndpoint('<html><head></head></html>'), null);
+  // the XML flavour is not parsed
+  assert.equal(findOEmbedEndpoint('<link rel="alternate" type="text/xml+oembed" href="https://x/o">'), null);
+});
+
+test('looksLikeAPObject accepts quotable content, rejects actors and activities', () => {
+  assert.ok(looksLikeAPObject(AP_NOTE));
+  assert.ok(looksLikeAPObject({ id: 'https://s/v/1', type: 'Video' }));
+  assert.ok(!looksLikeAPObject({ id: 'https://s/users/a', type: 'Person' }));
+  assert.ok(!looksLikeAPObject({ id: 'https://s/a/1', type: 'Create' }));
+  assert.ok(!looksLikeAPObject({ type: 'Note' }));      // no id
+  assert.ok(!looksLikeAPObject(null));
+});
+
+test('an ActivityPub object resolves over AP, never over oEmbed', async () => {
+  let pageFetched = false;
+  const r = await resolveEmbed('https://s/@alice/1', io({
+    getAP: async () => AP_NOTE,
+    getPage: async () => { pageFetched = true; return OEMBED_PAGE; },
+    getJSON: async () => OEMBED_JSON,
+    provider: () => ({ provider: 'youtube', id: 'x' }),   // must not win either
+  }));
+  assert.equal(r.kind, 'ap');
+  assert.equal(r.id, 'https://s/objects/1');
+  assert.equal(r.attributedTo, 'https://s/users/alice');
+  assert.equal(r.author.handle, '@alice@s');
+  assert.equal(r.url, 'https://s/@alice/1');
+  assert.ok(!pageFetched, 'AP wins before any oEmbed discovery happens');
+});
+
+test('a known provider beats oEmbed but loses to AP', async () => {
+  const r = await resolveEmbed('https://youtu.be/abcdefghijk', io({
+    provider: () => ({ provider: 'youtube', id: 'abcdefghijk' }),
+    getPage: async () => OEMBED_PAGE,
+    getJSON: async () => OEMBED_JSON,
+  }));
+  assert.equal(r.kind, 'provider');
+  assert.equal(r.provider, 'youtube');
+});
+
+test('anything else goes through oEmbed discovery', async () => {
+  const r = await resolveEmbed('https://v.example/watch/1', io({
+    getPage: async () => OEMBED_PAGE,
+    getJSON: async () => OEMBED_JSON,
+  }));
+  assert.equal(r.kind, 'oembed');
+  assert.equal(r.title, 'A talk');
+  assert.equal(r.author.name, 'Ada');
+  assert.equal(r.provider, 'Vid');
+  assert.equal(r.media[0].url, 'https://v.example/t.jpg');
+  assert.ok(r.html.startsWith('<iframe'));
+});
+
+test('no oEmbed link, or a dead endpoint, still yields a usable link card', async () => {
+  const noLink = await resolveEmbed('https://plain.example/p', io({ getPage: async () => '<html></html>' }));
+  assert.equal(noLink.kind, 'link');
+  const deadEndpoint = await resolveEmbed('https://v.example/p', io({
+    getPage: async () => OEMBED_PAGE, getJSON: async () => null,
+  }));
+  assert.equal(deadEndpoint.kind, 'link');
+});
+
+test('a failing AP fetch does not abort the pipeline', async () => {
+  const r = await resolveEmbed('https://v.example/p', io({
+    getAP: async () => { throw new Error('boom'); },
+    getPage: async () => OEMBED_PAGE,
+    getJSON: async () => OEMBED_JSON,
+  }));
+  assert.equal(r.kind, 'oembed');
+});
+
+test('non-http input is refused', async () => {
+  assert.equal(await resolveEmbed('javascript:alert(1)', io()), null);
+  assert.equal(await resolveEmbed('', io()), null);
+});
+
+test('fromOEmbed keeps only an http(s) canonical url', () => {
+  const c = fromOEmbed('https://a/b', { url: 'javascript:alert(1)', title: 'x' });
+  assert.equal(c.url, 'https://a/b');
+});
+
+// The live binding: the ordering above stays, but every fetch is capped and
+// goes through safeFetch, so a URL in a post cannot make us probe internals.
+test('liveIO caps oversized bodies and never throws on a bad fetch', async () => {
+  const { liveIO } = await import('../src/services/EmbedResolver.js');
+  const calls = [];
+  const fakeFetch = async (u, o) => {
+    calls.push([u, o.headers.Accept]);
+    if (u.includes('huge')) return { ok: true, headers: { get: () => String(9_000_000) }, text: async () => 'x' };
+    if (u.includes('boom')) throw new Error('refused');
+    return { ok: true, headers: { get: () => '10' }, text: async () => '{"type":"Note","id":"https://s/1"}' };
+  };
+  const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null });
+  assert.equal(await io.getPage('https://x/huge'), null, 'oversized body refused');
+  assert.equal(await io.getAP('https://x/boom'), null, 'a refused fetch is not an error');
+  assert.deepEqual(await io.getAP('https://x/ok'), { type: 'Note', id: 'https://s/1' });
+  assert.ok(calls.some((c) => c[1].includes('activity+json')), 'AP asks for activity+json');
+});
