← back to Marketing Command Center
Channels OAuth connect machinery: per-platform connect/callback (YouTube/Google, TikTok, Meta FB+IG) + token store + one-click Connect buttons; reads app client creds from env
a57c5b8bb21527999f4e5b1edbff65c1116d39d9 · 2026-06-10 09:17:40 -0700 · Steve
Files touched
M .gitignoreM modules/channels/index.jsM public/panels/channels.js
Diff
commit a57c5b8bb21527999f4e5b1edbff65c1116d39d9
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jun 10 09:17:40 2026 -0700
Channels OAuth connect machinery: per-platform connect/callback (YouTube/Google, TikTok, Meta FB+IG) + token store + one-click Connect buttons; reads app client creds from env
---
.gitignore | 1 +
modules/channels/index.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++-
public/panels/channels.js | 3 +-
3 files changed, 100 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index b3d024f..bbab7ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ data/*.cache.json
# ship code, not the library, so uploads aren't clobbered on redeploy.
data/assets.json
data/assets/
+data/channels-tokens.json
diff --git a/modules/channels/index.js b/modules/channels/index.js
index d472886..6ebfb63 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -15,9 +15,76 @@ const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8
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();
+// ── OAuth connect machinery ───────────────────────────────────────────────────
+// Tokens minted by the connect flow are stored here (gitignored), so a one-time
+// browser "Authorize" is all it takes once the app client id/secret are set.
+const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
+const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
+const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
+// Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
+// in each app console. Defaults to the live host; override with OAUTH_REDIRECT_BASE.
+const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.designerwallcoverings.com';
+const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;
+
+// Per-platform OAuth config. clientId/secret come from env (Steve pastes once).
+const OAUTH = {
+ youtube: {
+ clientId: () => env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID'),
+ clientSecret: () => env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET'),
+ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ scope: 'https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly',
+ extra: { access_type: 'offline', prompt: 'consent' },
+ },
+ tiktok: {
+ clientId: () => env('TIKTOK_CLIENT_KEY') || env('TIKTOK_CLIENT_ID'),
+ clientSecret: () => env('TIKTOK_CLIENT_SECRET'),
+ authUrl: 'https://www.tiktok.com/v2/auth/authorize/',
+ tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
+ scope: 'video.publish,video.upload,user.info.basic',
+ clientParam: 'client_key',
+ },
+ facebook: {
+ clientId: () => env('META_APP_ID'),
+ clientSecret: () => env('META_APP_SECRET'),
+ authUrl: 'https://www.facebook.com/v21.0/dialog/oauth',
+ tokenUrl: 'https://graph.facebook.com/v21.0/oauth/access_token',
+ scope: 'pages_manage_posts,pages_read_engagement,instagram_content_publish,instagram_basic,business_management',
+ },
+};
+OAUTH.instagram = OAUTH.facebook; // same Meta app/flow
+
+function authorizeUrl(p) {
+ const c = OAUTH[p]; if (!c || !c.clientId()) return null;
+ const params = new URLSearchParams({
+ [c.clientParam || 'client_id']: c.clientId(),
+ redirect_uri: redirectUri(p),
+ response_type: 'code',
+ scope: c.scope,
+ state: p,
+ ...(c.extra || {}),
+ });
+ return `${c.authUrl}?${params.toString()}`;
+}
+async function exchangeCode(p, code) {
+ const c = OAUTH[p];
+ const body = new URLSearchParams({
+ [c.clientParam || 'client_id']: c.clientId(),
+ client_secret: c.clientSecret(),
+ code, grant_type: 'authorization_code', redirect_uri: redirectUri(p),
+ });
+ const r = await fetch(c.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.error) throw new Error(j.error_description || j.error?.message || JSON.stringify(j).slice(0, 200));
+ return j; // {access_token, refresh_token?, expires_in?}
+}
+
// ── Per-platform connection status (what's wired vs what Steve must authorize) ──
function platformStatus() {
- return {
+ const tk = readTokens();
+ const cfg = (p) => !!(OAUTH[p] && OAUTH[p].clientId() && OAUTH[p].clientSecret());
+ const tokenConnected = (p) => !!(tk[p] && tk[p].access_token);
+ const base = {
facebook: {
label: 'Facebook Pages', icon: '📘',
connected: !!(env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN')),
@@ -47,6 +114,14 @@ function platformStatus() {
scopes: ['https://www.googleapis.com/auth/youtube.upload'],
},
};
+ // Merge in OAuth-flow state: a platform is `connected` if env creds OR a minted
+ // token exists; `configured` = app client id/secret present so a Connect button works.
+ for (const p of Object.keys(base)) {
+ base[p].configured = cfg(p);
+ base[p].connected = base[p].connected || tokenConnected(p);
+ base[p].connectUrl = (base[p].configured && !base[p].connected) ? `/api/channels/connect/${p}` : null;
+ }
+ return base;
}
// ── Real adapters (fire only when connected) ──────────────────────────────────
@@ -108,6 +183,27 @@ module.exports = {
});
router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
+ // OAuth: start the connect flow → redirect the browser to the platform consent.
+ router.get('/connect/:platform', (req, res) => {
+ const url = authorizeUrl(req.params.platform);
+ if (!url) return res.status(400).send(`Channel '${req.params.platform}' has no app client id/secret configured yet. Paste them into .env first.`);
+ res.redirect(url);
+ });
+ // OAuth callback: exchange the code, store the token, mark connected.
+ router.get('/oauth/:platform/callback', async (req, res) => {
+ const p = req.params.platform;
+ if (req.query.error) return res.status(400).send(`Authorization declined: ${req.query.error_description || req.query.error}`);
+ if (!req.query.code) return res.status(400).send('No authorization code returned.');
+ try {
+ const tok = await exchangeCode(p, req.query.code);
+ const all = readTokens();
+ all[p] = { ...tok, connectedAt: new Date().toISOString() };
+ if (p === 'facebook' || p === 'instagram') { all.facebook = all[p]; all.instagram = all[p]; }
+ writeTokens(all);
+ res.send(`<body style="font-family:system-ui;padding:50px;text-align:center"><h2>✓ ${p} connected</h2><p>You can close this tab and return to the Marketing Command Center.</p><a href="/#channels">← back to Channels</a></body>`);
+ } catch (e) { res.status(500).send(`Token exchange failed for ${p}: ${e.message}`); }
+ });
+
// The publish pipeline. Stages by default; only fires live with confirm + !dryRun
// AND a connected platform. Unconnected platforms always stage.
router.post('/publish', async (req, res) => {
diff --git a/public/panels/channels.js b/public/panels/channels.js
index de8a324..65ef279 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -14,7 +14,8 @@ window.MCC_PANELS['channels'] = {
<div style="display:flex;align-items:center;gap:8px"><span style="font-size:20px">${p.icon}</span><b>${esc(p.label)}</b>
<span class="pill" style="margin-left:auto;background:${p.connected ? '#e3efe0' : '#f3e9e9'};color:${p.connected ? '#3a6b3a' : '#9a5a5a'}">${p.connected ? 'connected' : 'not connected'}</span></div>
${p.accounts && p.accounts.length ? `<div class="muted" style="margin-top:6px;font-size:12px">${p.accounts.length} account(s)</div>` : ''}
- ${!p.connected ? `<div class="muted" style="margin-top:8px;font-size:11.5px"><b>To connect:</b> ${esc(p.needs)}</div>` : ''}
+ ${p.connectUrl ? `<a class="btn gold" style="margin-top:10px;display:inline-block;text-decoration:none" href="${esc(p.connectUrl)}">Connect ${esc(p.label)} →</a>`
+ : (!p.connected ? `<div class="muted" style="margin-top:8px;font-size:11.5px"><b>${p.configured ? 'Ready — connect above.' : 'Needs app creds:'}</b> ${esc(p.needs)}</div>` : '')}
</div>`).join('');
// targets
$('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>
← b546fbd Copy panel: Gemini path (preferred over dead Anthropic) → re
·
back to Marketing Command Center
·
Vendor IG: headless-verified follower counts (33 live, real 1cc1006 →