[object Object]

← back to Marketing Command Center

Channels: add Facebook/Instagram Page picker — select WHICH of your 80 Pages (+ 11 linked IG) to post to.

5435736ff0280a7406e169399e1803ee0152e8ba · 2026-06-15 12:18:59 -0700 · SteveStudio2

- /api/channels/pages discovers all Pages via META_ACCESS_TOKEN (/me/accounts), caches per-Page tokens + IG links to data/meta-pages.json (gitignored); tokens stripped before sending to browser.
- postFacebook/postInstagram now post per-selected-Page using each Page's own token (legacy FB_PAGE_IDS path preserved as fallback).
- Composer: searchable Page picker (All/None/IG-linked/refresh), selection persisted to localStorage, shown when FB/IG targeted; publish guard requires >=1 Page.
- platformStatus marks FB/IG connected when META_ACCESS_TOKEN present.
Tested locally: /pages 200 (80 pages, 11 IG, no token leak), no-auth 401.

Files touched

Diff

commit 5435736ff0280a7406e169399e1803ee0152e8ba
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon Jun 15 12:18:59 2026 -0700

    Channels: add Facebook/Instagram Page picker — select WHICH of your 80 Pages (+ 11 linked IG) to post to.
    
    - /api/channels/pages discovers all Pages via META_ACCESS_TOKEN (/me/accounts), caches per-Page tokens + IG links to data/meta-pages.json (gitignored); tokens stripped before sending to browser.
    - postFacebook/postInstagram now post per-selected-Page using each Page's own token (legacy FB_PAGE_IDS path preserved as fallback).
    - Composer: searchable Page picker (All/None/IG-linked/refresh), selection persisted to localStorage, shown when FB/IG targeted; publish guard requires >=1 Page.
    - platformStatus marks FB/IG connected when META_ACCESS_TOKEN present.
    Tested locally: /pages 200 (80 pages, 11 IG, no token leak), no-auth 401.
---
 .gitignore                  |   1 +
 modules/channels/index.js   | 132 ++++++++++++++++++++++++++++++++++----------
 public/panels/channels.html |  18 ++++++
 public/panels/channels.js   |  67 +++++++++++++++++++++-
 4 files changed, 186 insertions(+), 32 deletions(-)

diff --git a/.gitignore b/.gitignore
index 97cb27f..064d32b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ data/reposts.json
 
 # Meta go-live creds — real Page token, never commit
 scripts/meta-creds.env
+data/meta-pages.json
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 70a140d..1838634 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -14,6 +14,38 @@ const OUTBOX = path.join(__dirname, '..', '..', 'data', 'channels-outbox.json');
 const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8')); } catch { return []; } };
 const writeOutbox = (a) => { fs.mkdirSync(path.dirname(OUTBOX), { recursive: true }); fs.writeFileSync(OUTBOX, JSON.stringify(a, null, 2)); };
 const env = (k) => (process.env[k] || '').trim();
+const GRAPH = 'https://graph.facebook.com/v23.0';
+
+// ── Meta Pages cache (the user's 80 FB Pages + per-Page tokens + linked IG) ─────
+// Discovered from /me/accounts with META_ACCESS_TOKEN (a user token). Each Page
+// carries its OWN access_token (that's what you post to a Page with) + an optional
+// linked Instagram business account. Cached here so the picker + per-Page posting
+// don't re-hit Graph on every render. CONTAINS PAGE TOKENS → gitignored, never
+// sent to the browser (the /pages route sanitizes the tokens out).
+const META_PAGES = path.join(__dirname, '..', '..', 'data', 'meta-pages.json');
+const readMetaPages = () => { try { return JSON.parse(fs.readFileSync(META_PAGES, 'utf8')); } catch { return { fetchedAt: null, pages: [] }; } };
+const writeMetaPages = (o) => { fs.mkdirSync(path.dirname(META_PAGES), { recursive: true }); fs.writeFileSync(META_PAGES, JSON.stringify(o, null, 2)); };
+async function fetchMetaPages() {
+  const token = env('META_ACCESS_TOKEN');
+  if (!token) throw new Error('META_ACCESS_TOKEN not set — paste it into .env first');
+  let pages = [], url = `${GRAPH}/me/accounts?fields=name,id,access_token,instagram_business_account{id,username},tasks&limit=100&access_token=${encodeURIComponent(token)}`;
+  let guard = 0;
+  while (url && guard++ < 20) {
+    const r = await fetch(url); const j = await r.json();
+    if (j.error) throw new Error(j.error.message);
+    for (const p of (j.data || [])) pages.push({
+      id: p.id, name: p.name, token: p.access_token || null,
+      igId: p.instagram_business_account?.id || null,
+      igUsername: p.instagram_business_account?.username || null,
+      canPost: (p.tasks || []).includes('CREATE_CONTENT'),
+    });
+    url = j.paging?.next || null;
+  }
+  pages.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+  const cache = { fetchedAt: new Date().toISOString(), pages };
+  writeMetaPages(cache);
+  return cache;
+}
 
 // ── OAuth connect machinery ───────────────────────────────────────────────────
 // Tokens minted by the connect flow are stored here (gitignored), so a one-time
@@ -148,16 +180,20 @@ function platformStatus() {
   const base = {
     facebook: {
       label: 'Facebook Pages', icon: '📘',
-      connected: !!(env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN')),
-      accounts: env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : [],
-      needs: 'Meta app (App ID/Secret) + per-Page access tokens with pages_manage_posts. "All your FB accounts" = paste each Page ID + token.',
+      // A user META_ACCESS_TOKEN unlocks per-Page tokens (via /me/accounts) — that's
+      // "connected" for the page-picker flow, independent of the legacy single-page env.
+      connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN'))),
+      accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.map(p => p.id)
+        : (env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : []),
+      needs: 'Meta user token (META_ACCESS_TOKEN) → per-Page tokens auto-discovered. Live posting needs pages_manage_posts on the token.',
       scopes: ['pages_manage_posts', 'pages_read_engagement', 'business_management'],
     },
     instagram: {
       label: 'Instagram', icon: '📸',
-      connected: !!(env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID')),
-      accounts: env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : [],
-      needs: 'IG Business account linked to a FB Page + long-lived token with instagram_content_publish (same Meta app as Facebook).',
+      connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID'))),
+      accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.filter(p => p.igId).map(p => p.igUsername || p.igId)
+        : (env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : []),
+      needs: 'IG Business account linked to a selected Page. Live posting needs instagram_content_publish on the token.',
       scopes: ['instagram_content_publish', 'instagram_basic'],
     },
     tiktok: {
@@ -190,34 +226,57 @@ function platformStatus() {
 }
 
 // ── Real adapters (fire only when connected) ──────────────────────────────────
-async function postFacebook(content) {
-  const token = env('FB_PAGE_ACCESS_TOKEN'); const pages = env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean);
+async function postFacebook(content, opts = {}) {
+  // Two modes: (a) selected-pages — post to a chosen subset using each Page's OWN
+  // token from the cache; (b) legacy — FB_PAGE_IDS + one FB_PAGE_ACCESS_TOKEN.
+  let targets = [];
+  if (opts.pages && opts.pages.length) {
+    const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
+    for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.token) targets.push({ id: p.id, token: p.token, name: p.name }); }
+    if (!targets.length) return [{ ok: false, error: 'selected Pages not in cache — click ↻ refresh on the Pages picker' }];
+  } else {
+    const token = env('FB_PAGE_ACCESS_TOKEN');
+    targets = env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean).map(id => ({ id, token, name: id }));
+  }
   const results = [];
-  for (const pageId of pages) {
-    const url = `https://graph.facebook.com/v21.0/${pageId}/${content.mediaUrl ? 'photos' : 'feed'}`;
-    const body = content.mediaUrl ? { url: content.mediaUrl, caption: content.caption, access_token: token }
-                                  : { message: content.caption, access_token: token };
+  for (const t of targets) {
+    const url = `${GRAPH}/${t.id}/${content.mediaUrl ? 'photos' : 'feed'}`;
+    const body = content.mediaUrl ? { url: content.mediaUrl, caption: content.caption, access_token: t.token }
+                                  : { message: content.caption, access_token: t.token };
     const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
     const j = await r.json().catch(() => ({}));
-    results.push({ pageId, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
+    results.push({ pageId: t.id, name: t.name, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
   }
   return results;
 }
-async function postInstagram(content) {
-  const token = env('IG_ACCESS_TOKEN'); const igId = env('IG_BUSINESS_ACCOUNT_ID');
+async function postInstagram(content, opts = {}) {
   if (!content.mediaUrl) return [{ ok: false, error: 'Instagram requires an image/video URL' }];
-  const create = await fetch(`https://graph.facebook.com/v21.0/${igId}/media`, {
-    method: 'POST', headers: { 'content-type': 'application/json' },
-    body: JSON.stringify({ image_url: content.mediaUrl, caption: content.caption, access_token: token }),
-  });
-  const cj = await create.json().catch(() => ({}));
-  if (!create.ok || !cj.id) return [{ ok: false, error: cj.error?.message || 'media create failed' }];
-  const pub = await fetch(`https://graph.facebook.com/v21.0/${igId}/media_publish`, {
-    method: 'POST', headers: { 'content-type': 'application/json' },
-    body: JSON.stringify({ creation_id: cj.id, access_token: token }),
-  });
-  const pj = await pub.json().catch(() => ({}));
-  return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') }];
+  // selected-pages → post to each selected Page's LINKED IG account (Page token);
+  // legacy → single IG_BUSINESS_ACCOUNT_ID + IG_ACCESS_TOKEN.
+  let igTargets = [];
+  if (opts.pages && opts.pages.length) {
+    const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
+    for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.igId && p.token) igTargets.push({ igId: p.igId, token: p.token, name: p.igUsername || p.name }); }
+    if (!igTargets.length) return [{ ok: false, error: 'none of the selected Pages have a linked Instagram account' }];
+  } else {
+    igTargets = [{ igId: env('IG_BUSINESS_ACCOUNT_ID'), token: env('IG_ACCESS_TOKEN'), name: 'IG' }];
+  }
+  const results = [];
+  for (const t of igTargets) {
+    const create = await fetch(`${GRAPH}/${t.igId}/media`, {
+      method: 'POST', headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ image_url: content.mediaUrl, caption: content.caption, access_token: t.token }),
+    });
+    const cj = await create.json().catch(() => ({}));
+    if (!create.ok || !cj.id) { results.push({ igId: t.igId, name: t.name, ok: false, error: cj.error?.message || 'media create failed' }); continue; }
+    const pub = await fetch(`${GRAPH}/${t.igId}/media_publish`, {
+      method: 'POST', headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ creation_id: cj.id, access_token: t.token }),
+    });
+    const pj = await pub.json().catch(() => ({}));
+    results.push({ igId: t.igId, name: t.name, ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') });
+  }
+  return results;
 }
 async function postTikTok(content) {
   // TikTok Content Posting API (direct post). Requires audited app.
@@ -248,6 +307,18 @@ module.exports = {
     });
     router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
 
+    // The Page picker's data source: the user's FB Pages (+ linked IG), discovered
+    // from /me/accounts. Tokens are STRIPPED before sending to the browser. Pass
+    // ?refresh=1 to re-fetch from Graph; otherwise serves the cache.
+    router.get('/pages', async (req, res) => {
+      try {
+        let cache = readMetaPages();
+        if (req.query.refresh === '1' || !cache.pages.length) cache = await fetchMetaPages();
+        const pages = cache.pages.map(p => ({ id: p.id, name: p.name, igUsername: p.igUsername, hasIG: !!p.igId, canPost: !!p.canPost }));
+        res.json({ fetchedAt: cache.fetchedAt, count: pages.length, igCount: pages.filter(p => p.hasIG).length, hasToken: !!env('META_ACCESS_TOKEN'), pages });
+      } catch (e) { res.status(500).json({ error: e.message }); }
+    });
+
     // Activate page data: provider cards + which keys are already set (booleans only,
     // never the values) + the redirect URIs to register in each portal.
     router.get('/setup', (_req, res) => {
@@ -310,6 +381,7 @@ module.exports = {
       try {
         const d = req.body || {};
         const channels = Array.isArray(d.channels) ? d.channels.filter(c => ADAPTERS[c]) : [];
+        const selectedPages = Array.isArray(d.pages) ? d.pages.map(String).filter(Boolean) : [];
         const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '' };
         if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
         if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
@@ -320,9 +392,9 @@ module.exports = {
         for (const ch of channels) {
           const connected = st[ch].connected;
           if (live && connected) {
-            const r = await ADAPTERS[ch](content).catch(e => [{ ok: false, error: e.message }]);
-            const ok = r.every(x => x.ok);
-            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
+            const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
+            const ok = r.length > 0 && r.every(x => x.ok);
+            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
             results.push({ channel: ch, live: true, ok, detail: r });
           } else {
             const reason = !connected ? 'pending-connection' : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
diff --git a/public/panels/channels.html b/public/panels/channels.html
index 0af3813..0fdc8c9 100644
--- a/public/panels/channels.html
+++ b/public/panels/channels.html
@@ -19,6 +19,24 @@
       <input type="text" id="ch-media" placeholder="https://… (use the Layouts or Assets panel)">
       <label style="margin-top:10px">Post to</label>
       <div class="row" id="ch-targets"></div>
+
+      <!-- Page picker: appears when Facebook/Instagram is selected. Choose WHICH
+           of your FB Pages (and their linked IG accounts) the post goes to. -->
+      <div id="ch-pages-wrap" style="margin-top:12px;display:none">
+        <label>Pages <span class="muted" id="ch-pages-count" style="font-weight:400"></span></label>
+        <div class="row" style="gap:6px;margin:4px 0;align-items:center">
+          <input type="text" id="ch-pages-search" placeholder="filter pages…" style="flex:1;min-width:150px">
+          <button class="btn ghost xs" id="ch-pages-all" type="button">All</button>
+          <button class="btn ghost xs" id="ch-pages-none" type="button">None</button>
+          <button class="btn ghost xs" id="ch-pages-ig" type="button" title="select only Pages with a linked Instagram">📸 IG-linked</button>
+          <button class="btn ghost xs" id="ch-pages-refresh" type="button" title="re-fetch your Pages from Meta">↻</button>
+        </div>
+        <div id="ch-pages" style="max-height:240px;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px">
+          <div class="muted">Loading pages…</div>
+        </div>
+        <div class="muted" id="ch-pages-note" style="font-size:11px;margin-top:4px"></div>
+      </div>
+
       <div class="row" style="margin-top:14px;align-items:center">
         <button class="btn gold" id="ch-stage">Stage post</button>
         <button class="btn gated" id="ch-publish">🚀 Publish live…</button>
diff --git a/public/panels/channels.js b/public/panels/channels.js
index c04ccfd..9938654 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -16,8 +16,67 @@ window.MCC_PANELS['channels'] = {
         (status.connectedCount === 0 ? 'No channels connected yet — activate each platform below to enable live posting.' : 'Connected channels post live; the rest stage.');
       $('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>
         `<label style="display:flex;align-items:center;gap:6px;font-weight:600;color:var(--ink)" ${p.caveat ? `title="${esc(p.caveat)}"` : ''}><input type="checkbox" class="ch-t" value="${k}" style="width:auto"> ${p.icon} ${esc(p.label)}${p.connected ? '' : ' <span class="muted" style="font-weight:400">(stages)</span>'}${p.caveat ? ' <span class="muted" style="font-weight:400" title="' + esc(p.caveat) + '">⚠</span>' : ''}</label>`).join('');
+      // the Page picker is only relevant for Facebook/Instagram → show it when either is ticked
+      root.querySelectorAll('.ch-t').forEach(c => c.addEventListener('change', syncPagesVisibility));
+      syncPagesVisibility();
     }
 
+    // ── Page picker (select WHICH FB Pages / IG accounts to post to) ──────────
+    const PAGE_KEY = 'mcc-ch-pages';
+    let allPages = [], pagesLoaded = false;
+    const selectedPages = new Set((() => { try { return JSON.parse(localStorage.getItem(PAGE_KEY) || '[]'); } catch { return []; } })());
+    const savePageSel = () => { try { localStorage.setItem(PAGE_KEY, JSON.stringify([...selectedPages])); } catch {} };
+    const metaSelected = () => [...root.querySelectorAll('.ch-t:checked')].some(c => c.value === 'facebook' || c.value === 'instagram');
+
+    function syncPagesVisibility() {
+      const wrap = $('#ch-pages-wrap'); if (!wrap) return;
+      const show = metaSelected();
+      wrap.style.display = show ? 'block' : 'none';
+      if (show && !pagesLoaded) loadPages(false);
+    }
+
+    function renderPages() {
+      const box = $('#ch-pages'); if (!box) return;
+      const q = ($('#ch-pages-search') && $('#ch-pages-search').value.trim().toLowerCase()) || '';
+      const igOnly = metaSelected() && [...root.querySelectorAll('.ch-t:checked')].every(c => c.value === 'instagram');
+      let list = allPages.filter(p => !q || (p.name || '').toLowerCase().includes(q));
+      if (igOnly) list = list.filter(p => p.hasIG); // posting IG-only → only IG-linked pages can receive it
+      box.innerHTML = list.length ? list.map(p =>
+        `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
+           <input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
+           <span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(p.name)}</span>
+           ${p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : ''}
+         </label>`).join('') : '<div class="muted">No pages match.</div>';
+      box.querySelectorAll('.ch-pg').forEach(c => c.addEventListener('change', () => {
+        if (c.checked) selectedPages.add(c.value); else selectedPages.delete(c.value);
+        savePageSel(); updatePagesCount();
+      }));
+      updatePagesCount();
+    }
+    function updatePagesCount() {
+      const el = $('#ch-pages-count'); if (!el) return;
+      el.textContent = `· ${selectedPages.size} of ${allPages.length} selected`;
+    }
+    async function loadPages(refresh) {
+      const box = $('#ch-pages'); if (box) box.innerHTML = '<div class="muted">Loading pages…</div>';
+      try {
+        const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
+        if (d.error) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(d.error)}</div>`; return; }
+        allPages = d.pages || []; pagesLoaded = true;
+        // drop any stale selections no longer in the account
+        const ids = new Set(allPages.map(p => p.id));
+        [...selectedPages].forEach(id => { if (!ids.has(id)) selectedPages.delete(id); });
+        $('#ch-pages-note').textContent = `${d.count} Pages · ${d.igCount} with Instagram · fetched ${fmtWhen(d.fetchedAt)}`;
+        renderPages();
+      } catch (e) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(e.message)}</div>`; }
+    }
+    // picker controls
+    if ($('#ch-pages-search')) $('#ch-pages-search').addEventListener('input', renderPages);
+    if ($('#ch-pages-all')) $('#ch-pages-all').onclick = () => { allPages.forEach(p => selectedPages.add(p.id)); savePageSel(); renderPages(); };
+    if ($('#ch-pages-none')) $('#ch-pages-none').onclick = () => { selectedPages.clear(); savePageSel(); renderPages(); };
+    if ($('#ch-pages-ig')) $('#ch-pages-ig').onclick = () => { selectedPages.clear(); allPages.filter(p => p.hasIG).forEach(p => selectedPages.add(p.id)); savePageSel(); renderPages(); };
+    if ($('#ch-pages-refresh')) $('#ch-pages-refresh').onclick = () => loadPages(true);
+
     // ── ① Activate page ──────────────────────────────────────────────────────
     async function loadSetup() {
       const d = await jget('/api/channels/setup');
@@ -127,16 +186,20 @@ window.MCC_PANELS['channels'] = {
     async function send(live) {
       const channels = [...root.querySelectorAll('.ch-t:checked')].map(c => c.value);
       const caption = $('#ch-caption').value.trim(); const mediaUrl = $('#ch-media').value.trim();
+      const pages = [...selectedPages];
       if (!channels.length) { $('#ch-msg').textContent = 'pick at least one channel'; return; }
       if (!caption && !mediaUrl) { $('#ch-msg').textContent = 'write a caption or add media'; return; }
+      // Facebook/Instagram need at least one Page selected (that's where the post goes)
+      if (metaSelected() && !pages.length) { $('#ch-msg').textContent = 'select at least one Page to post to (Pages picker above)'; return; }
       if (live) {
         const ps = status.platforms || {};
         const caveats = channels.filter(c => ps[c] && ps[c].caveat).map(c => `• ${ps[c].label}: ${ps[c].caveat}`);
         const warn = caveats.length ? `\n\n⚠ Heads up:\n${caveats.join('\n')}` : '';
-        if (!confirm(`PUBLISH LIVE to: ${channels.join(', ')}?\n\nConnected channels post immediately; unconnected ones stage.${warn}\n\nProceed?`)) return;
+        const pageNote = metaSelected() ? `\n\nFacebook/Instagram → ${pages.length} selected Page(s).` : '';
+        if (!confirm(`PUBLISH LIVE to: ${channels.join(', ')}?${pageNote}\n\nConnected channels post immediately; unconnected ones stage.${warn}\n\nProceed?`)) return;
       }
       $('#ch-msg').textContent = live ? 'publishing…' : 'staging…';
-      const body = { channels, caption, mediaUrl, confirm: live, dryRun: !live };
+      const body = { channels, caption, mediaUrl, pages, confirm: live, dryRun: !live };
       const r = await fetch(ORIGIN + '/api/channels/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) });
       const d = await r.json();
       $('#ch-msg').textContent = d.error ? '✗ ' + d.error : (d.note || 'done');

← a32a889 Add Streams Board — Hootsuite-style column view (social chan  ·  back to Marketing Command Center  ·  Add Meta App Review prep doc (System-User route + full revie e76aecd →