← back to Marketing Command Center
channels: consolidated /health probe (fb/ig/tiktok/linkedin/threads/bluesky/youtube) + Quick Post per-platform token dots
f874f5b2a042ff062f3b50ac540930854ca480b3 · 2026-08-12 16:55:10 -0700 · Steve
Files touched
M modules/channels/index.jsM package.jsonM public/quickpost.js
Diff
commit f874f5b2a042ff062f3b50ac540930854ca480b3
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Aug 12 16:55:10 2026 -0700
channels: consolidated /health probe (fb/ig/tiktok/linkedin/threads/bluesky/youtube) + Quick Post per-platform token dots
---
modules/channels/index.js | 52 +++++++++++++++++++++++++++++++++++++++++++++++
package.json | 2 +-
public/quickpost.js | 42 ++++++++++++++++++++++++++++++++++++--
3 files changed, 93 insertions(+), 3 deletions(-)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index c9ca981..5fd8a79 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -757,6 +757,58 @@ module.exports = {
res.json({ ok: true, valid: false, error_message: String(e.message || e).slice(0, 140) });
}
});
+
+ // Consolidated read-only token-VALIDITY probe across every posting platform.
+ // Each runs a lightweight real API call (never a post) with the SAME token the
+ // publish path uses, so "connected" (presence) can't mask an expired token.
+ // state: valid | invalid | not_configured | unknown. Tokens never returned.
+ router.get('/health', async (_req, res) => {
+ const TO = (p, ms = 12000) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))]);
+ const err = e => String((e && e.message) || e).slice(0, 90);
+
+ async function metaState() {
+ try { const h = await metaTokenHealth(); return h.ok === true ? 'valid' : h.ok === false ? 'invalid' : (h.present ? 'unknown' : 'not_configured'); }
+ catch { return 'unknown'; }
+ }
+ async function tiktokState() {
+ const t = env('TIKTOK_ACCESS_TOKEN') || (readTokens().tiktok || {}).access_token || '';
+ if (!t) return { valid: false, state: 'not_configured' };
+ try {
+ const r = await TO(fetch('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', { method: 'POST', headers: { authorization: `Bearer ${t}`, 'content-type': 'application/json; charset=UTF-8' }, body: '{}' }));
+ const j = await r.json().catch(() => ({})); const ok = r.ok && (j.error && j.error.code) === 'ok';
+ return { valid: ok, state: ok ? 'valid' : 'invalid', reason: ok ? null : (j.error && j.error.message) || null, last4: t.slice(-4) };
+ } catch (e) { return { valid: false, state: 'unknown', reason: err(e) }; }
+ }
+ async function linkedinState() {
+ const t = env('LINKEDIN_ACCESS_TOKEN'); if (!t) return { valid: false, state: 'not_configured' };
+ try { const r = await TO(fetch('https://api.linkedin.com/v2/userinfo', { headers: { authorization: `Bearer ${t}` } })); return { valid: r.ok, state: r.ok ? 'valid' : 'invalid', http: r.status, last4: t.slice(-4) }; }
+ catch (e) { return { valid: false, state: 'unknown', reason: err(e) }; }
+ }
+ async function threadsState() {
+ const t = env('THREADS_ACCESS_TOKEN'), uid = env('THREADS_USER_ID'); if (!t || !uid) return { valid: false, state: 'not_configured' };
+ try { const r = await TO(fetch(`https://graph.threads.net/v1.0/${uid}?fields=username&access_token=${encodeURIComponent(t)}`)); const j = await r.json().catch(() => ({})); const ok = r.ok && !j.error; return { valid: ok, state: ok ? 'valid' : 'invalid', username: j.username || null, reason: ok ? null : (j.error && j.error.message) || null, last4: t.slice(-4) }; }
+ catch (e) { return { valid: false, state: 'unknown', reason: err(e) }; }
+ }
+ async function blueskyState() {
+ const handle = env('BLUESKY_HANDLE'), pw = env('BLUESKY_APP_PASSWORD'); if (!handle || !pw) return { valid: false, state: 'not_configured' };
+ try { const r = await TO(fetch('https://bsky.social/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identifier: handle, password: pw }) })); const j = await r.json().catch(() => ({})); const ok = r.ok && !!j.accessJwt; return { valid: ok, state: ok ? 'valid' : 'invalid', handle: ok ? handle : null, reason: ok ? null : (j.message || j.error || `HTTP ${r.status}`) }; }
+ catch (e) { return { valid: false, state: 'unknown', reason: err(e) }; }
+ }
+ async function youtubeState() {
+ try { const tok = await TO(youtubeAccessToken()); return { valid: !!tok, state: tok ? 'valid' : 'invalid' }; }
+ catch (e) { return { valid: false, state: 'unknown', reason: err(e) }; }
+ }
+
+ const [meta, tiktok, linkedin, threads, bluesky, youtube] = await Promise.all([
+ metaState(), tiktokState(), linkedinState(), threadsState(), blueskyState(), youtubeState(),
+ ]);
+ const platforms = {
+ facebook: { valid: meta === 'valid', state: meta },
+ instagram: { valid: meta === 'valid', state: meta },
+ tiktok, linkedin, threads, bluesky, youtube,
+ };
+ res.json({ ok: true, platforms, checkedAt: new Date().toISOString() });
+ });
// Localize expiring IG/fbcdn media to durable local copies before serving
// (see lib/media-cache): cached→local path synchronously, background-fetch
// any missing so the next load self-heals. Never blocks the response.
diff --git a/package.json b/package.json
index addd357..60fa139 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "marketing-command-center",
- "version": "1.10.2",
+ "version": "1.10.3",
"description": "DW Marketing Command Center — Constant Contact, marketing calendar, suggested copy, on-demand layouts",
"main": "server.js",
"scripts": {
diff --git a/public/quickpost.js b/public/quickpost.js
index 96bfa0e..48c83b6 100644
--- a/public/quickpost.js
+++ b/public/quickpost.js
@@ -34,6 +34,11 @@
.qp-all{border-style:dashed;color:var(--accent,#8a5a44)}
.qp-btn.qp-live{border-color:var(--accent,#8a5a44);color:var(--accent,#8a5a44);font-weight:700}
.qp-btn.qp-live:hover{background:var(--accent,#8a5a44);color:#fff}
+ .qp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-left:4px;flex:none;background:#c9bfa8}
+ .qp-dot.valid{background:#3a6b3a}
+ .qp-dot.invalid{background:#c0563f}
+ .qp-legend{font-size:10.5px;color:var(--mut,#8a8275);margin-top:6px;display:flex;gap:12px;flex-wrap:wrap}
+ .qp-legend b{font-weight:600;color:var(--ink,#14110f)}
.qp-hint{font-size:11px;color:var(--mut,#8a8275)}
#qp-toast{position:fixed;right:18px;bottom:18px;z-index:9999;display:flex;flex-direction:column;gap:8px}
#qp-toast .t{background:var(--ink,#14110f);color:#fff;border-radius:10px;padding:11px 15px;font:600 12.5px/1.3 Inter,sans-serif;
@@ -60,8 +65,31 @@
return r.json().catch(() => ({ error: 'bad response' }));
}
+ // Cached real token-validity per platform (GET /api/channels/health). One fetch
+ // shared across every mount so the dots don't hammer the endpoint.
+ let _healthPromise = null;
+ function getHealth() {
+ if (!_healthPromise) _healthPromise = fetch(ORIGIN + '/api/channels/health', { credentials: 'same-origin' })
+ .then(r => r.json()).then(d => (d && d.platforms) || {}).catch(() => ({}));
+ return _healthPromise;
+ }
+ function decorateHealth(mount) {
+ getHealth().then(h => {
+ mount.querySelectorAll('.qp-btn[data-qp]').forEach(btn => {
+ const id = btn.getAttribute('data-qp');
+ const st = h[id] && h[id].state;
+ if (!st) return;
+ const dot = document.createElement('span');
+ dot.className = 'qp-dot ' + (st === 'valid' ? 'valid' : st === 'invalid' ? 'invalid' : '');
+ dot.title = 'Token: ' + st;
+ btn.appendChild(dot);
+ btn.title = (btn.title || '') + ' · token ' + st;
+ });
+ });
+ }
+
// Render a button row into `mount`. getPayload(platform) → {caption, mediaUrl, source}.
- // opts: { mini:false, includeAll:true }
+ // opts: { mini:false, includeAll:true, live:false, health:true }
function attach(mount, getPayload, opts) {
opts = opts || {};
const cls = opts.mini ? 'qp-btn mini' : 'qp-btn';
@@ -109,7 +137,17 @@
setTimeout(() => { btn.classList.remove('ok'); btn.innerHTML = orig; }, 1600);
document.dispatchEvent(new CustomEvent('qp:staged'));
}));
+
+ // token-validity dots (green=valid · red=expired · grey=unverified/not set) —
+ // shown on the full (non-mini) rows so tiny asset-card buttons stay clean.
+ if (opts.health !== false && !opts.mini) {
+ decorateHealth(mount);
+ mount.insertAdjacentHTML('beforeend',
+ `<div class="qp-legend"><span><span class="qp-dot valid"></span> token valid</span>` +
+ `<span><span class="qp-dot invalid"></span> expired</span>` +
+ `<span><span class="qp-dot"></span> not set / unverified</span></div>`);
+ }
}
- window.MCCQuickPost = { PLATFORMS, attach, stage, toast };
+ window.MCCQuickPost = { PLATFORMS, attach, stage, toast, getHealth };
})();
← a89c005 channels: add read-only GET /tiktok-health token-validity pr
·
back to Marketing Command Center
·
deploy: /channels/health probe + Quick Post token dots live aaf5279 →